Skip to content

Commit

Permalink
feat(core): add 'full' array insert menu
Browse files Browse the repository at this point in the history
  • Loading branch information
christianhg committed May 24, 2024
1 parent d51d764 commit 79ecbd5
Show file tree
Hide file tree
Showing 3 changed files with 251 additions and 43 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {AddIcon} from '@sanity/icons'
import {type ArraySchemaType, isReferenceSchemaType} from '@sanity/types'
import {type ArraySchemaType} from '@sanity/types'
import {Grid, Menu} from '@sanity/ui'
import {useCallback, useId} from 'react'

Expand All @@ -12,6 +12,7 @@ import {
} from '../../../../../ui-components'
import {useTranslation} from '../../../../i18n'
import {type ArrayInputFunctionsProps, type ObjectItem} from '../../../types'
import {FullInsertMenuButton, getSchemaTypeIcon} from './InsertMenu'

const POPOVER_PROPS: MenuButtonProps['popover'] = {
constrainSize: true,
Expand Down Expand Up @@ -50,56 +51,75 @@ export function ArrayOfObjectsFunctions<
? 'inputs.array.action.add-item-select-type'
: 'inputs.array.action.add-item'

const insertButtonProps: React.ComponentProps<typeof Button> = {
icon: AddIcon,
mode: 'ghost',
size: 'large',
text: t(addItemI18nKey),
}

if (readOnly) {
return (
<Tooltip portal content={t('inputs.array.read-only-label')}>
<Grid>
<Button icon={AddIcon} mode="ghost" disabled size="large" text={t(addItemI18nKey)} />
<Button {...insertButtonProps} disabled />
</Grid>
</Tooltip>
)
}

return (
<Grid gap={1} style={{gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))'}}>
{schemaType.of.length === 1 ? (
<Button
icon={AddIcon}
mode="ghost"
onClick={handleAddBtnClick}
size="large"
text={t(addItemI18nKey)}
/>
) : (
<MenuButton
button={<Button icon={AddIcon} mode="ghost" size="large" text={t(addItemI18nKey)} />}
id={menuButtonId || ''}
menu={
<Menu>
{schemaType.of.map((memberDef, i) => {
// Use reference icon if reference is to one schemaType only
const referenceIcon =
isReferenceSchemaType(memberDef) &&
(memberDef.to || []).length === 1 &&
memberDef.to[0].icon
if (schemaType.of.length === 1) {
return (
<Container>
<Button {...insertButtonProps} onClick={handleAddBtnClick} />
{children}
</Container>
)
}

const icon = memberDef.icon || memberDef.type?.icon || referenceIcon
return (
<MenuItem
key={i}
text={memberDef.title || memberDef.type?.name}
onClick={() => insertItem(memberDef)}
icon={icon}
/>
)
})}
</Menu>
}
popover={POPOVER_PROPS}
if (schemaType.options?.insertMenu?.layout === 'full') {
return (
<Container>
<FullInsertMenuButton
insertButtonProps={insertButtonProps}
schemaTypes={schemaType.of}
onSelect={insertItem}
/>
)}
{children}
</Container>
)
}

return (
<Container>
<MenuButton
button={<Button {...insertButtonProps} />}
id={menuButtonId || ''}
menu={
<Menu>
{schemaType.of.map((memberDef, i) => {
return (
<MenuItem
key={i}
text={memberDef.title || memberDef.type?.name}
onClick={() => insertItem(memberDef)}
icon={getSchemaTypeIcon(memberDef)}
/>
)
})}
</Menu>
}
popover={POPOVER_PROPS}
/>
{children}
</Container>
)
}

function Container(props: React.PropsWithChildren<unknown>) {
return (
<Grid gap={1} style={{gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))'}}>
{props.children}
</Grid>
)
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,30 @@
/* eslint-disable react/jsx-no-bind */
import {InsertAboveIcon, InsertBelowIcon} from '@sanity/icons'
import {type SchemaType} from '@sanity/types'
import {type ComponentProps, memo} from 'react'
import {InsertAboveIcon, InsertBelowIcon, SearchIcon, ThLargeIcon, UlistIcon} from '@sanity/icons'
import {type ArraySchemaType, isReferenceSchemaType, type SchemaType} from '@sanity/types'
import {
Box,
Flex,
Grid,
Menu,
Popover,
Stack,
Text,
TextInput,
useClickOutside,
useGlobalKeyDown,
} from '@sanity/ui'
import {
type ChangeEvent,
type ComponentProps,
memo,
useCallback,
useMemo,
useReducer,
useState,
} from 'react'

import {MenuGroup, MenuItem, type PopoverProps} from '../../../../../ui-components'
import {useTranslation} from '../../../../i18n'
import {Button, MenuGroup, MenuItem, type PopoverProps} from '../../../../../ui-components'
import {type StudioLocaleResourceKeys, useTranslation} from '../../../../i18n'

interface Props {
types?: SchemaType[]
Expand Down Expand Up @@ -66,3 +86,167 @@ function InsertMenuGroup(
</MenuGroup>
)
}

export function FullInsertMenuButton<TSchemaType extends ArraySchemaType>(props: {
insertButtonProps: React.ComponentProps<typeof Button>
schemaTypes: TSchemaType['of']
onSelect: (schemaType: TSchemaType['of'][number]) => void
}) {
const [insertMenuOpen, setInsertMenuOpen] = useState(false)
const [button, setButton] = useState<HTMLButtonElement | null>(null)
const [popover, setPopover] = useState<HTMLDivElement | null>(null)

useClickOutside(
useCallback(() => {
setInsertMenuOpen(false)
}, []),
[button, popover],
)

useGlobalKeyDown(
useCallback(
(event: KeyboardEvent) => {
if (event.key === 'Escape') {
setInsertMenuOpen(false)
button?.focus()
}
},
[button],
),
)

const {onSelect} = props
const handleOnSelect = useCallback(
(schemaType: TSchemaType['of'][number]) => {
onSelect(schemaType)
setInsertMenuOpen(false)
},
[onSelect],
)

return (
<Popover
constrainSize
content={<FullInsertMenu schemaTypes={props.schemaTypes} onSelect={handleOnSelect} />}
fallbackPlacements={['top', 'bottom']}
matchReferenceWidth
open={insertMenuOpen}
overflow="hidden"
placement="bottom"
portal
ref={setPopover}
>
<Button
{...props.insertButtonProps}
ref={setButton}
selected={insertMenuOpen}
onClick={() => {
setInsertMenuOpen((open) => !open)
}}
/>
</Popover>
)
}

type FullInsertMenuView = 'list' | 'grid'

function FullInsertMenu<TSchemaType extends ArraySchemaType>(props: {
schemaTypes: TSchemaType['of']
onSelect: (schemaType: TSchemaType['of'][number]) => void
}) {
const {t} = useTranslation()
const [view, toggleView] = useReducer(
(state: FullInsertMenuView) => (state === 'list' ? 'grid' : 'list'),
'list',
)
const [query, setQuery] = useState('')

const filteredSchemaTypes = useMemo(
() => filterSchemaTypes(props.schemaTypes, query),
[query, props.schemaTypes],
)

const ViewContainer = view === 'grid' ? Grid : Stack
const viewToggleIcon: Record<FullInsertMenuView, React.ElementType> = {
grid: UlistIcon,
list: ThLargeIcon,
}
const viewToggleTooltip: Record<FullInsertMenuView, StudioLocaleResourceKeys> = {
grid: 'inputs.array.toggle-list-view.tooltip',
list: 'inputs.array.toggle-grid-view.tooltip',
}

return (
<Flex direction="column" height="fill">
<Flex flex="none" padding={1} gap={1}>
<Box flex={1}>
<TextInput
autoFocus
fontSize={1}
icon={SearchIcon}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value)
}}
placeholder={t('inputs.array.search.placeholder')}
value={query}
/>
</Box>
<Box flex="none">
<Button
mode="bleed"
icon={viewToggleIcon[view]}
onClick={toggleView}
tooltipProps={{content: t(viewToggleTooltip[view])}}
/>
</Box>
</Flex>
<Menu>
{filteredSchemaTypes.length === 0 ? (
<Box padding={3}>
<Text align="center" muted size={1}>
{t('inputs.array.search.no-result')}
</Text>
</Box>
) : (
<ViewContainer columns={3}>
{filteredSchemaTypes.map((schemaType) => (
<MenuItem
key={schemaType.name}
text={schemaType.title ?? schemaType.name}
onClick={() => {
props.onSelect(schemaType)
}}
icon={getSchemaTypeIcon(schemaType)}
/>
))}
</ViewContainer>
)}
</Menu>
</Flex>
)
}

export function getSchemaTypeIcon<TSchemaType extends ArraySchemaType>(
schemaType: TSchemaType['of'][number],
) {
// Use reference icon if reference is to one schemaType only
const referenceIcon =
isReferenceSchemaType(schemaType) && (schemaType.to ?? []).length === 1
? schemaType.to[0].icon
: undefined

return schemaType.icon ?? schemaType.type?.icon ?? referenceIcon
}

function filterSchemaTypes<TSchemaType extends ArraySchemaType>(
schemaTypes: TSchemaType['of'],
query: string,
) {
const sanitizedQuery = query.trim().toLowerCase()

return schemaTypes.filter(
(schemaType) =>
schemaType.title?.toLowerCase().includes(sanitizedQuery) ||
schemaType.name.includes(sanitizedQuery),
)
}
4 changes: 4 additions & 0 deletions packages/sanity/src/core/i18n/bundles/studio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,10 @@ export const studioLocaleStrings = defineLocalesResources('studio', {
'inputs.array.read-only-label': 'This field is read-only',
/** Label for when the array input is resolving the initial value for the item */
'inputs.array.resolving-initial-value': 'Resolving initial value…',
'inputs.array.search.no-results': 'No items found',
'inputs.array.search.placeholder': 'Search',
'inputs.array.toggle-grid-view.tooltip': 'Toggle grid view',
'inputs.array.toggle-list-view.tooltip': 'Toggle list view',
/** Tooltip content when boolean input is disabled */
'inputs.boolean.disabled': 'Disabled',
/** Placeholder value for datetime input */
Expand Down

0 comments on commit 79ecbd5

Please sign in to comment.