This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import EmojiPickerInner from './Inner'
const meta = {
title: 'Base/Data Entry/EmojiPickerInner',
component: EmojiPickerInner,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'Core emoji grid with search and style swatches. Use this when embedding the selector inline without a modal frame.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof EmojiPickerInner>
export default meta
type Story = StoryObj<typeof meta>
const InnerDemo = () => {
const [selection, setSelection] = useState<{ emoji: string; background: string } | null>(null)
return (
<div className="flex h-[520px] flex-col gap-4 rounded-xl border border-divider-subtle bg-components-panel-bg p-6 shadow-lg">
<EmojiPickerInner
onSelect={(emoji, background) => setSelection({ emoji, background })}
className="flex-1 overflow-hidden rounded-xl border border-divider-subtle bg-white"
/>
<div className="rounded-lg border border-divider-subtle bg-background-default-subtle p-3 text-xs text-text-secondary">
<div className="font-medium text-text-primary">Latest selection</div>
<pre className="mt-1 max-h-40 overflow-auto font-mono">
{selection ? JSON.stringify(selection, null, 2) : 'Tap an emoji to set background options.'}
</pre>
</div>
</div>
)
}
export const Playground: Story = {
render: () => <InnerDemo />,
parameters: {
docs: {
source: {
language: 'tsx',
code: `
const [selection, setSelection] = useState<{ emoji: string; background: string } | null>(null)
return (
<EmojiPickerInner onSelect={(emoji, background) => setSelection({ emoji, background })} />
)
`.trim(),
},
},
},
}

View File

@@ -0,0 +1,171 @@
'use client'
import type { ChangeEvent, FC } from 'react'
import React, { useState } from 'react'
import data from '@emoji-mart/data'
import type { EmojiMartData } from '@emoji-mart/data'
import { init } from 'emoji-mart'
import {
ChevronDownIcon,
ChevronUpIcon,
MagnifyingGlassIcon,
} from '@heroicons/react/24/outline'
import Input from '@/app/components/base/input'
import Divider from '@/app/components/base/divider'
import { searchEmoji } from '@/utils/emoji'
import cn from '@/utils/classnames'
init({ data })
const backgroundColors = [
'#FFEAD5',
'#E4FBCC',
'#D3F8DF',
'#E0F2FE',
'#E0EAFF',
'#EFF1F5',
'#FBE8FF',
'#FCE7F6',
'#FEF7C3',
'#E6F4D7',
'#D5F5F6',
'#D1E9FF',
'#D1E0FF',
'#D5D9EB',
'#ECE9FE',
'#FFE4E8',
]
type IEmojiPickerInnerProps = {
emoji?: string
background?: string
onSelect?: (emoji: string, background: string) => void
className?: string
}
const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({
onSelect,
className,
}) => {
const { categories } = data as EmojiMartData
const [selectedEmoji, setSelectedEmoji] = useState('')
const [selectedBackground, setSelectedBackground] = useState(backgroundColors[0])
const [showStyleColors, setShowStyleColors] = useState(false)
const [searchedEmojis, setSearchedEmojis] = useState<string[]>([])
const [isSearching, setIsSearching] = useState(false)
React.useEffect(() => {
if (selectedEmoji) {
setShowStyleColors(true)
if (selectedBackground)
onSelect?.(selectedEmoji, selectedBackground)
}
}, [onSelect, selectedEmoji, selectedBackground])
return <div className={cn(className, 'flex flex-col')}>
<div className='flex w-full flex-col items-center px-3 pb-2'>
<div className="relative w-full">
<div className="pointer-events-none absolute inset-y-0 left-0 z-10 flex items-center pl-3">
<MagnifyingGlassIcon className="h-5 w-5 text-text-quaternary" aria-hidden="true" />
</div>
<Input
className="pl-10"
type="search"
id="search"
placeholder="Search emojis..."
onChange={async (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.value === '') {
setIsSearching(false)
}
else {
setIsSearching(true)
const emojis = await searchEmoji(e.target.value)
setSearchedEmojis(emojis)
}
}}
/>
</div>
</div>
<Divider className='my-3' />
<div className="max-h-[200px] w-full overflow-y-auto overflow-x-hidden px-3">
{isSearching && <>
<div key={'category-search'} className='flex flex-col'>
<p className='system-xs-medium-uppercase mb-1 text-text-primary'>Search</p>
<div className='grid h-full w-full grid-cols-8 gap-1'>
{searchedEmojis.map((emoji: string, index: number) => {
return <div
key={`emoji-search-${index}`}
className='inline-flex h-10 w-10 items-center justify-center rounded-lg'
onClick={() => {
setSelectedEmoji(emoji)
}}
>
<div className='flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg p-1 ring-components-input-border-hover ring-offset-1 hover:ring-1'>
<em-emoji id={emoji} />
</div>
</div>
})}
</div>
</div>
</>}
{categories.map((category, index: number) => {
return <div key={`category-${index}`} className='flex flex-col'>
<p className='system-xs-medium-uppercase mb-1 text-text-primary'>{category.id}</p>
<div className='grid h-full w-full grid-cols-8 gap-1'>
{category.emojis.map((emoji, index: number) => {
return <div
key={`emoji-${index}`}
className='inline-flex h-10 w-10 items-center justify-center rounded-lg'
onClick={() => {
setSelectedEmoji(emoji)
}}
>
<div className='flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg p-1 ring-components-input-border-hover ring-offset-1 hover:ring-1'>
<em-emoji id={emoji} />
</div>
</div>
})}
</div>
</div>
})}
</div>
{/* Color Select */}
<div className={cn('flex items-center justify-between p-3 pb-0')}>
<p className='system-xs-medium-uppercase mb-2 text-text-primary'>Choose Style</p>
{showStyleColors ? <ChevronDownIcon className='h-4 w-4 cursor-pointer text-text-quaternary' onClick={() => setShowStyleColors(!showStyleColors)} />
: <ChevronUpIcon className='h-4 w-4 cursor-pointer text-text-quaternary' onClick={() => setShowStyleColors(!showStyleColors)} />}
</div>
{showStyleColors && <div className='grid w-full grid-cols-8 gap-1 px-3'>
{backgroundColors.map((color) => {
return <div
key={color}
className={
cn(
'cursor-pointer',
'ring-offset-1 hover:ring-1',
'inline-flex h-10 w-10 items-center justify-center rounded-lg',
color === selectedBackground ? 'ring-1 ring-components-input-border-hover' : '',
)}
onClick={() => {
setSelectedBackground(color)
}}
>
<div className={cn(
'flex h-8 w-8 items-center justify-center rounded-lg p-1',
)
} style={{ background: color }}>
{selectedEmoji !== '' && <em-emoji id={selectedEmoji} />}
</div>
</div>
})}
</div>}
</div>
}
export default EmojiPickerInner

View File

@@ -0,0 +1,91 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import EmojiPicker from '.'
const meta = {
title: 'Base/Data Entry/EmojiPicker',
component: EmojiPicker,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'Modal-based emoji selector that powers the icon picker. Supports search, background swatches, and confirmation callbacks.',
},
},
nextjs: {
appDirectory: true,
navigation: {
pathname: '/apps/demo-app/emoji-picker',
params: { appId: 'demo-app' },
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof EmojiPicker>
export default meta
type Story = StoryObj<typeof meta>
const EmojiPickerDemo = () => {
const [open, setOpen] = useState(false)
const [selection, setSelection] = useState<{ emoji: string; background: string } | null>(null)
return (
<div className="flex min-h-[320px] flex-col items-start gap-4 px-6 py-8 md:px-12">
<button
type="button"
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700"
onClick={() => setOpen(true)}
>
Open emoji picker
</button>
<div className="rounded-lg border border-divider-subtle bg-components-panel-bg p-4 text-sm text-text-secondary shadow-sm">
<div className="font-medium text-text-primary">Selection preview</div>
<pre className="mt-2 max-h-44 overflow-auto rounded-md bg-background-default-subtle p-3 font-mono text-xs leading-tight text-text-primary">
{selection ? JSON.stringify(selection, null, 2) : 'No emoji selected yet.'}
</pre>
</div>
{open && (
<EmojiPicker
onSelect={(emoji, background) => {
setSelection({ emoji, background })
setOpen(false)
}}
onClose={() => setOpen(false)}
/>
)}
</div>
)
}
export const Playground: Story = {
render: () => <EmojiPickerDemo />,
parameters: {
docs: {
source: {
language: 'tsx',
code: `
const [open, setOpen] = useState(false)
const [selection, setSelection] = useState<{ emoji: string; background: string } | null>(null)
return (
<>
<button onClick={() => setOpen(true)}>Open emoji picker…</button>
{open && (
<EmojiPicker
onSelect={(emoji, background) => {
setSelection({ emoji, background })
setOpen(false)
}}
onClose={() => setOpen(false)}
/>
)}
</>
)
`.trim(),
},
},
},
}

View File

@@ -0,0 +1,65 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import EmojiPickerInner from './Inner'
import cn from '@/utils/classnames'
import Divider from '@/app/components/base/divider'
import Button from '@/app/components/base/button'
import Modal from '@/app/components/base/modal'
import { noop } from 'lodash-es'
type IEmojiPickerProps = {
isModal?: boolean
onSelect?: (emoji: string, background: string) => void
onClose?: () => void
className?: string
}
const EmojiPicker: FC<IEmojiPickerProps> = ({
isModal = true,
onSelect,
onClose,
className,
}) => {
const { t } = useTranslation()
const [selectedEmoji, setSelectedEmoji] = useState('')
const [selectedBackground, setSelectedBackground] = useState<string>()
const handleSelectEmoji = useCallback((emoji: string, background: string) => {
setSelectedEmoji(emoji)
setSelectedBackground(background)
}, [setSelectedEmoji, setSelectedBackground])
return isModal
? <Modal
onClose={noop}
isShow
closable={false}
wrapperClassName={className}
className={cn('flex max-h-[552px] flex-col rounded-xl border-[0.5px] border-divider-subtle p-0 shadow-xl')}
>
<EmojiPickerInner
className="pt-3"
onSelect={handleSelectEmoji} />
<Divider className='mb-0 mt-3' />
<div className='flex w-full items-center justify-center gap-2 p-3'>
<Button className='w-full' onClick={() => {
onClose?.()
}}>
{t('app.iconPicker.cancel')}
</Button>
<Button
disabled={selectedEmoji === '' || !selectedBackground}
variant="primary"
className='w-full'
onClick={() => {
onSelect?.(selectedEmoji, selectedBackground!)
}}>
{t('app.iconPicker.ok')}
</Button>
</div>
</Modal>
: <></>
}
export default EmojiPicker