2
0

⚗️ Implement bot v2 MVP (#194)

Closes #190
This commit is contained in:
Baptiste Arnaud
2022-12-22 17:02:34 +01:00
committed by GitHub
parent e55823e011
commit 1a3869ae6d
202 changed files with 8060 additions and 1152 deletions

View File

@ -0,0 +1,133 @@
import { LiteBadge } from './LiteBadge'
import { createSignal, onCleanup, onMount, Show } from 'solid-js'
import { getViewerUrl, isDefined, isNotEmpty } from 'utils'
import { getInitialChatReplyQuery } from '@/queries/getInitialChatReplyQuery'
import { ConversationContainer } from './ConversationContainer'
import css from '../assets/index.css'
import { InitialChatReply, StartParams } from 'models'
import { setIsMobile } from '@/utils/isMobileSignal'
import { BotContext } from '@/types'
import { injectHeadCode } from '@/utils/injectHeadCode'
export type BotProps = StartParams & {
initialChatReply?: InitialChatReply
apiHost?: string
}
export const Bot = (props: BotProps) => {
const [initialChatReply, setInitialChatReply] = createSignal<
InitialChatReply | undefined
>(props.initialChatReply)
onMount(() => {
if (!props.typebotId) return
const initialChatReplyValue = initialChatReply()
if (isDefined(initialChatReplyValue)) {
const customHeadCode =
initialChatReplyValue.typebot.settings.metadata.customHeadCode
if (customHeadCode) {
injectHeadCode(customHeadCode)
}
} else {
const urlParams = new URLSearchParams(location.search)
const prefilledVariables: { [key: string]: string } = {}
urlParams.forEach((value, key) => {
prefilledVariables[key] = value
})
getInitialChatReplyQuery({
typebotId: props.typebotId,
apiHost: props.apiHost,
isPreview: props.isPreview ?? false,
resultId: props.resultId,
prefilledVariables: {
...prefilledVariables,
...props.prefilledVariables,
},
}).then((initialChatReply) => {
setInitialChatReply(initialChatReply)
})
}
})
return (
<Show
when={isNotEmpty(props.apiHost ?? getViewerUrl())}
fallback={() => (
<p>process.env.NEXT_PUBLIC_VIEWER_URL is missing in env</p>
)}
>
<Show when={initialChatReply()} keyed>
{(initialChatReply) => (
<BotContent
initialChatReply={initialChatReply}
context={{
apiHost: props.apiHost,
isPreview: props.isPreview ?? false,
typebotId: props.typebotId as string,
resultId: initialChatReply.resultId,
}}
/>
)}
</Show>
</Show>
)
}
type BotContentProps = {
initialChatReply: InitialChatReply
context: BotContext
}
const BotContent = (props: BotContentProps) => {
let botContainer: HTMLDivElement | undefined
const resizeObserver = new ResizeObserver((entries) => {
return setIsMobile(entries[0].target.clientWidth < 400)
})
const injectCustomFont = () => {
const font = document.createElement('link')
font.href = `https://fonts.googleapis.com/css2?family=${
props.initialChatReply.typebot?.theme?.general?.font ?? 'Open Sans'
}:wght@300;400;600&display=swap')`
font.rel = 'stylesheet'
document.head.appendChild(font)
}
onMount(() => {
injectCustomFont()
if (botContainer) {
resizeObserver.observe(botContainer)
}
})
onCleanup(() => {
if (botContainer) {
resizeObserver.unobserve(botContainer)
}
})
return (
<>
<style>{css}</style>
<div
ref={botContainer}
class="flex w-full h-full text-base overflow-hidden bg-cover flex-col items-center typebot-container"
>
<div class="flex w-full h-full justify-center">
<ConversationContainer
context={props.context}
initialChatReply={props.initialChatReply}
/>
</div>
<Show
when={
props.initialChatReply.typebot.settings.general.isBrandingEnabled
}
>
<LiteBadge botContainer={botContainer} />
</Show>
</div>
</>
)
}

View File

@ -0,0 +1,49 @@
import { createSignal, onCleanup, onMount } from 'solid-js'
import { Avatar } from '@/components/avatars/Avatar'
import { isMobile } from '@/utils/isMobileSignal'
type Props = { hostAvatarSrc?: string }
export const AvatarSideContainer = (props: Props) => {
let avatarContainer: HTMLDivElement | undefined
const [top, setTop] = createSignal<number>(0)
const resizeObserver = new ResizeObserver((entries) =>
setTop(entries[0].target.clientHeight - (isMobile() ? 24 : 40))
)
onMount(() => {
if (avatarContainer) {
resizeObserver.observe(avatarContainer)
}
})
onCleanup(() => {
if (avatarContainer) {
resizeObserver.unobserve(avatarContainer)
}
})
return (
<div
ref={avatarContainer}
class={
'flex w-10 mr-2 mb-2 flex-shrink-0 items-center relative typebot-avatar-container ' +
(isMobile() ? 'w-6' : 'w-10')
}
>
<div
class={
'absolute mb-2 flex items-center top-0 ' +
(isMobile() ? 'w-6 h-6' : 'w-10 h-10')
}
style={{
top: `${top()}px`,
transition: 'top 350ms ease-out',
}}
>
<Avatar avatarSrc={props.hostAvatarSrc} />
</div>
</div>
)
}

View File

@ -0,0 +1,73 @@
import { BotContext } from '@/types'
import { ChatReply, Settings, Theme } from 'models'
import { createSignal, For, Show } from 'solid-js'
import { HostBubble } from '../bubbles/HostBubble'
import { InputChatBlock } from '../InputChatBlock'
import { AvatarSideContainer } from './AvatarSideContainer'
type Props = Pick<ChatReply, 'messages' | 'input'> & {
theme: Theme
settings: Settings
inputIndex: number
context: BotContext
onSubmit: (input: string) => void
onSkip: () => void
}
export const ChatChunk = (props: Props) => {
const [displayedMessageIndex, setDisplayedMessageIndex] = createSignal(0)
const displayNextMessage = () => {
setDisplayedMessageIndex(
displayedMessageIndex() === props.messages.length
? displayedMessageIndex()
: displayedMessageIndex() + 1
)
}
return (
<div class="flex w-full">
<div class="flex flex-col w-full min-w-0">
<div class="flex">
<Show
when={
props.theme.chat.hostAvatar?.isEnabled &&
props.messages.length > 0
}
>
<AvatarSideContainer
hostAvatarSrc={props.theme.chat.hostAvatar?.url}
/>
</Show>
<div
class="flex-1"
style={{
'margin-right': props.theme.chat.guestAvatar?.isEnabled
? '50px'
: '0.5rem',
}}
>
<For each={props.messages.slice(0, displayedMessageIndex() + 1)}>
{(message) => (
<HostBubble
message={message}
onTransitionEnd={displayNextMessage}
/>
)}
</For>
</div>
</div>
{props.input && displayedMessageIndex() === props.messages.length && (
<InputChatBlock
block={props.input}
inputIndex={props.inputIndex}
onSubmit={props.onSubmit}
onSkip={props.onSkip}
guestAvatar={props.theme.chat.guestAvatar}
context={props.context}
/>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,64 @@
import { ChatReply, InitialChatReply } from 'models'
import { createSignal, For } from 'solid-js'
import { sendMessageQuery } from '@/queries/sendMessageQuery'
import { ChatChunk } from './ChatChunk'
import { BotContext } from '@/types'
import { executeIntegrations } from '@/utils/executeIntegrations'
import { executeLogic } from '@/utils/executeLogic'
type Props = {
initialChatReply: InitialChatReply
context: BotContext
}
export const ConversationContainer = (props: Props) => {
const [chatChunks, setChatChunks] = createSignal<ChatReply[]>([
{
input: props.initialChatReply.input,
messages: props.initialChatReply.messages,
},
])
const sendMessage = async (message: string) => {
const data = await sendMessageQuery({
apiHost: props.context.apiHost,
sessionId: props.initialChatReply.sessionId,
message,
})
if (!data) return
if (data.integrations) {
executeIntegrations(data.integrations)
}
if (data.logic) {
await executeLogic(data.logic)
}
setChatChunks((displayedChunks) => [
...displayedChunks,
{
input: data.input,
messages: data.messages,
},
])
}
return (
<div class="overflow-y-scroll w-full lg:w-3/4 min-h-full rounded lg:px-5 px-3 pt-10 relative scrollable-container typebot-chat-view">
<For each={chatChunks()}>
{(chatChunk, index) => (
<ChatChunk
inputIndex={index()}
messages={chatChunk.messages}
input={chatChunk.input}
theme={props.initialChatReply.typebot.theme}
settings={props.initialChatReply.typebot.settings}
onSubmit={sendMessage}
onSkip={() => {
// TODO: implement skip
}}
context={props.context}
/>
)}
</For>
</div>
)
}

View File

@ -0,0 +1 @@
export * from './ConversationContainer'

View File

@ -0,0 +1,182 @@
import {
ChatReply,
ChoiceInputBlock,
DateInputOptions,
EmailInputBlock,
FileInputBlock,
InputBlockType,
NumberInputBlock,
PaymentInputOptions,
PhoneNumberInputBlock,
RatingInputBlock,
RuntimeOptions,
TextInputBlock,
Theme,
UrlInputBlock,
} from 'models'
import { GuestBubble } from './bubbles/GuestBubble'
import { BotContext, InputSubmitContent } from '@/types'
import { TextInput } from '@/features/blocks/inputs/textInput'
import { NumberInput } from '@/features/blocks/inputs/number'
import { EmailInput } from '@/features/blocks/inputs/email'
import { UrlInput } from '@/features/blocks/inputs/url'
import { PhoneInput } from '@/features/blocks/inputs/phone'
import { DateForm } from '@/features/blocks/inputs/date'
import { ChoiceForm } from '@/features/blocks/inputs/buttons'
import { RatingForm } from '@/features/blocks/inputs/rating'
import { FileUploadForm } from '@/features/blocks/inputs/fileUpload'
import { createSignal, Switch, Match } from 'solid-js'
import { isNotDefined } from 'utils'
import { isMobile } from '@/utils/isMobileSignal'
import { PaymentForm } from '@/features/blocks/inputs/payment'
type Props = {
block: NonNullable<ChatReply['input']>
guestAvatar?: Theme['chat']['guestAvatar']
inputIndex: number
context: BotContext
onSubmit: (answer: string) => void
onSkip: () => void
}
export const InputChatBlock = (props: Props) => {
const [answer, setAnswer] = createSignal<string>()
const handleSubmit = async ({ label, value }: InputSubmitContent) => {
setAnswer(label ?? value)
props.onSubmit(value)
}
return (
<Switch>
<Match when={answer()} keyed>
{(answer) => (
<GuestBubble
message={answer}
showAvatar={props.guestAvatar?.isEnabled ?? false}
avatarSrc={props.guestAvatar?.url && props.guestAvatar.url}
/>
)}
</Match>
<Match when={isNotDefined(answer())}>
<div class="flex justify-end animate-fade-in">
{props.guestAvatar?.isEnabled && (
<div
class={
'flex mr-2 mb-2 mt-1 flex-shrink-0 items-center ' +
(isMobile() ? 'w-6 h-6' : 'w-10 h-10')
}
/>
)}
<Input
context={props.context}
block={props.block}
inputIndex={props.inputIndex}
onSubmit={handleSubmit}
onSkip={() => props.onSkip()}
hasGuestAvatar={props.guestAvatar?.isEnabled ?? false}
/>
</div>
</Match>
</Switch>
)
}
const Input = (props: {
context: BotContext
block: NonNullable<ChatReply['input']>
inputIndex: number
hasGuestAvatar: boolean
onSubmit: (answer: InputSubmitContent) => void
onSkip: () => void
}) => {
const onSubmit = (answer: InputSubmitContent) => props.onSubmit(answer)
return (
<Switch>
<Match when={props.block.type === InputBlockType.TEXT}>
<TextInput
block={props.block as TextInputBlock & { prefilledValue?: string }}
onSubmit={onSubmit}
hasGuestAvatar={props.hasGuestAvatar}
/>
</Match>
<Match when={props.block.type === InputBlockType.NUMBER}>
<NumberInput
block={props.block as NumberInputBlock & { prefilledValue?: string }}
onSubmit={onSubmit}
hasGuestAvatar={props.hasGuestAvatar}
/>
</Match>
<Match when={props.block.type === InputBlockType.EMAIL}>
<EmailInput
block={props.block as EmailInputBlock & { prefilledValue?: string }}
onSubmit={onSubmit}
hasGuestAvatar={props.hasGuestAvatar}
/>
</Match>
<Match when={props.block.type === InputBlockType.URL}>
<UrlInput
block={props.block as UrlInputBlock & { prefilledValue?: string }}
onSubmit={onSubmit}
hasGuestAvatar={props.hasGuestAvatar}
/>
</Match>
<Match when={props.block.type === InputBlockType.PHONE}>
<PhoneInput
block={
props.block as PhoneNumberInputBlock & { prefilledValue?: string }
}
onSubmit={onSubmit}
hasGuestAvatar={props.hasGuestAvatar}
/>
</Match>
<Match when={props.block.type === InputBlockType.DATE}>
<DateForm
options={props.block.options as DateInputOptions}
onSubmit={onSubmit}
/>
</Match>
<Match when={props.block.type === InputBlockType.CHOICE}>
<ChoiceForm
inputIndex={props.inputIndex}
block={props.block as ChoiceInputBlock}
onSubmit={onSubmit}
/>
</Match>
<Match when={props.block.type === InputBlockType.RATING}>
<RatingForm
block={props.block as RatingInputBlock & { prefilledValue?: string }}
onSubmit={onSubmit}
/>
</Match>
<Match when={props.block.type === InputBlockType.FILE}>
<FileUploadForm
context={props.context}
block={props.block as FileInputBlock}
onSubmit={onSubmit}
// eslint-disable-next-line solid/reactivity
onSkip={props.onSkip}
/>
</Match>
<Match when={props.block.type === InputBlockType.PAYMENT}>
<PaymentForm
context={props.context}
options={
{
...props.block.options,
...props.block.runtimeOptions,
} as PaymentInputOptions & RuntimeOptions
}
onSuccess={() =>
props.onSubmit({
value:
(props.block.options as PaymentInputOptions).labels.success ??
'Success',
})
}
/>
</Match>
</Switch>
)
}

View File

@ -0,0 +1,48 @@
import { onCleanup, onMount } from 'solid-js'
type Props = {
botContainer: HTMLDivElement | undefined
}
export const LiteBadge = (props: Props) => {
let liteBadge: HTMLAnchorElement | undefined
let observer: MutationObserver | undefined
onMount(() => {
if (!document || !props.botContainer) return
observer = new MutationObserver(function (mutations_list) {
mutations_list.forEach(function (mutation) {
mutation.removedNodes.forEach(function (removed_node) {
if (
'id' in removed_node &&
liteBadge &&
removed_node.id == 'lite-badge'
)
props.botContainer?.append(liteBadge)
})
})
})
observer.observe(props.botContainer, {
subtree: false,
childList: true,
})
})
onCleanup(() => {
if (observer) observer.disconnect()
})
return (
<a
ref={liteBadge}
href={'https://www.typebot.io/?utm_source=litebadge'}
target="_blank"
rel="noopener noreferrer"
class="fixed py-1 px-2 bg-white z-50 rounded shadow-md lite-badge"
style={{ bottom: '20px' }}
id="lite-badge"
>
Made with <span class="text-blue-500">Typebot</span>.
</a>
)
}

View File

@ -0,0 +1,59 @@
import { isMobile } from '@/utils/isMobileSignal'
import { Show } from 'solid-js'
import { JSX } from 'solid-js/jsx-runtime'
import { SendIcon } from './icons'
type SendButtonProps = {
isDisabled?: boolean
isLoading?: boolean
disableIcon?: boolean
} & JSX.ButtonHTMLAttributes<HTMLButtonElement>
export const SendButton = (props: SendButtonProps) => {
return (
<button
type="submit"
disabled={props.isDisabled || props.isLoading}
{...props}
class={
'py-2 px-4 justify-center font-semibold rounded-md text-white focus:outline-none flex items-center disabled:opacity-50 disabled:cursor-not-allowed disabled:brightness-100 transition-all filter hover:brightness-90 active:brightness-75 typebot-button ' +
props.class
}
>
<Show when={!props.isLoading} fallback={<Spinner class="text-white" />}>
{isMobile() && !props.disableIcon ? (
<SendIcon
class={'send-icon flex ' + (props.disableIcon ? 'hidden' : '')}
/>
) : (
props.children
)}
</Show>
</button>
)
}
export const Spinner = (props: JSX.SvgSVGAttributes<SVGSVGElement>) => (
<svg
{...props}
class={'animate-spin -ml-1 mr-3 h-5 w-5 ' + props.class}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
data-testid="loading-spinner"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
)

View File

@ -0,0 +1,24 @@
import { isMobile } from '@/utils/isMobileSignal'
import { Show } from 'solid-js'
import { DefaultAvatar } from './DefaultAvatar'
export const Avatar = (props: { avatarSrc?: string }) => (
<Show when={props.avatarSrc !== ''}>
<Show when={props.avatarSrc} keyed fallback={() => <DefaultAvatar />}>
{(currentAvatarSrc) => (
<figure
class={
'flex justify-center items-center rounded-full text-white relative animate-fade-in ' +
(isMobile() ? 'w-6 h-6 text-sm' : 'w-10 h-10 text-xl')
}
>
<img
src={currentAvatarSrc}
alt="Bot avatar"
class="rounded-full object-cover w-full h-full"
/>
</figure>
)}
</Show>
</Show>
)

View File

@ -0,0 +1,54 @@
import { isMobile } from '@/utils/isMobileSignal'
export const DefaultAvatar = () => {
return (
<figure
class={
'flex justify-center items-center rounded-full text-white relative ' +
(isMobile() ? 'w-6 h-6 text-sm' : 'w-10 h-10 text-xl')
}
data-testid="default-avatar"
>
<svg
width="75"
height="75"
viewBox="0 0 75 75"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class={
'absolute top-0 left-0 ' +
(isMobile() ? ' w-6 h-6 text-sm' : 'w-full h-full text-xl')
}
>
<mask id="mask0" x="0" y="0" mask-type="alpha">
<circle cx="37.5" cy="37.5" r="37.5" fill="#0042DA" />
</mask>
<g mask="url(#mask0)">
<rect x="-30" y="-43" width="131" height="154" fill="#0042DA" />
<rect
x="2.50413"
y="120.333"
width="81.5597"
height="86.4577"
rx="2.5"
transform="rotate(-52.6423 2.50413 120.333)"
stroke="#FED23D"
stroke-width="5"
/>
<circle
cx="76.5"
cy="-1.5"
r="29"
stroke="#FF8E20"
stroke-width="5"
/>
<path
d="M-49.8224 22L-15.5 -40.7879L18.8224 22H-49.8224Z"
stroke="#F7F8FF"
stroke-width="5"
/>
</g>
</svg>
</figure>
)
}

View File

@ -0,0 +1,26 @@
import { Show } from 'solid-js'
import { isDefined } from 'utils'
import { Avatar } from '../avatars/Avatar'
type Props = {
message: string
showAvatar: boolean
avatarSrc?: string
}
export const GuestBubble = (props: Props) => (
<div
class="flex justify-end mb-2 items-end animate-fade-in"
style={{ 'margin-left': '50px' }}
>
<span
class="px-4 py-2 rounded-lg mr-2 whitespace-pre-wrap max-w-full typebot-guest-bubble cursor-pointer"
data-testid="guest-bubble"
>
{props.message}
</span>
<Show when={isDefined(props.avatarSrc)}>
<Avatar avatarSrc={props.avatarSrc} />
</Show>
</div>
)

View File

@ -0,0 +1,61 @@
import { AudioBubble } from '@/features/blocks/bubbles/audio'
import { EmbedBubble } from '@/features/blocks/bubbles/embed'
import { ImageBubble } from '@/features/blocks/bubbles/image'
import { TextBubble } from '@/features/blocks/bubbles/textBubble'
import { VideoBubble } from '@/features/blocks/bubbles/video'
import {
AudioBubbleContent,
BubbleBlockType,
ChatMessage,
EmbedBubbleContent,
ImageBubbleContent,
TextBubbleContent,
VideoBubbleContent,
} from 'models'
import { Match, Switch } from 'solid-js'
type Props = {
message: ChatMessage
onTransitionEnd: () => void
}
export const HostBubble = (props: Props) => {
const onTransitionEnd = () => {
props.onTransitionEnd()
}
return (
<Switch>
<Match when={props.message.type === BubbleBlockType.TEXT}>
<TextBubble
content={props.message.content as Omit<TextBubbleContent, 'richText'>}
onTransitionEnd={onTransitionEnd}
/>
</Match>
<Match when={props.message.type === BubbleBlockType.IMAGE}>
<ImageBubble
url={(props.message.content as ImageBubbleContent).url}
onTransitionEnd={onTransitionEnd}
/>
</Match>
<Match when={props.message.type === BubbleBlockType.VIDEO}>
<VideoBubble
content={props.message.content as VideoBubbleContent}
onTransitionEnd={onTransitionEnd}
/>
</Match>
<Match when={props.message.type === BubbleBlockType.EMBED}>
<EmbedBubble
content={props.message.content as EmbedBubbleContent}
onTransitionEnd={onTransitionEnd}
/>
</Match>
<Match when={props.message.type === BubbleBlockType.AUDIO}>
<AudioBubble
url={(props.message.content as AudioBubbleContent).url}
onTransitionEnd={onTransitionEnd}
/>
</Match>
</Switch>
)
}

View File

@ -0,0 +1,7 @@
export const TypingBubble = () => (
<div class="flex items-center">
<div class="w-2 h-2 mr-1 rounded-full bubble1" />
<div class="w-2 h-2 mr-1 rounded-full bubble2" />
<div class="w-2 h-2 rounded-full bubble3" />
</div>
)

View File

@ -0,0 +1,13 @@
import { JSX } from 'solid-js/jsx-runtime'
export const SendIcon = (props: JSX.SvgSVGAttributes<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
width="19px"
color="white"
{...props}
>
<path d="M476.59 227.05l-.16-.07L49.35 49.84A23.56 23.56 0 0027.14 52 24.65 24.65 0 0016 72.59v113.29a24 24 0 0019.52 23.57l232.93 43.07a4 4 0 010 7.86L35.53 303.45A24 24 0 0016 327v113.31A23.57 23.57 0 0026.59 460a23.94 23.94 0 0013.22 4 24.55 24.55 0 009.52-1.93L476.4 285.94l.19-.09a32 32 0 000-58.8z" />
</svg>
)

View File

@ -0,0 +1 @@
export * from './SendIcon'

View File

@ -0,0 +1,23 @@
import { isMobile } from '@/utils/isMobileSignal'
import { splitProps } from 'solid-js'
import { JSX } from 'solid-js/jsx-runtime'
type ShortTextInputProps = {
ref: HTMLInputElement | undefined
onInput: (value: string) => void
} & Omit<JSX.InputHTMLAttributes<HTMLInputElement>, 'onInput'>
export const ShortTextInput = (props: ShortTextInputProps) => {
const [local, others] = splitProps(props, ['ref', 'onInput'])
return (
<input
ref={local.ref}
class="focus:outline-none bg-transparent px-4 py-4 flex-1 w-full text-input"
type="text"
style={{ 'font-size': '16px' }}
autofocus={!isMobile()}
onInput={(e) => local.onInput(e.currentTarget.value)}
{...others}
/>
)
}

View File

@ -0,0 +1,26 @@
import { isMobile } from '@/utils/isMobileSignal'
import { splitProps } from 'solid-js'
import { JSX } from 'solid-js/jsx-runtime'
type TextareaProps = {
ref: HTMLTextAreaElement | undefined
onInput: (value: string) => void
} & Omit<JSX.TextareaHTMLAttributes<HTMLTextAreaElement>, 'onInput'>
export const Textarea = (props: TextareaProps) => {
const [local, others] = splitProps(props, ['ref', 'onInput'])
return (
<textarea
ref={local.ref}
class="focus:outline-none bg-transparent px-4 py-4 flex-1 w-full text-input"
rows={6}
data-testid="textarea"
required
style={{ 'font-size': '16px' }}
autofocus={!isMobile()}
onInput={(e) => local.onInput(e.currentTarget.value)}
{...others}
/>
)
}

View File

@ -0,0 +1,2 @@
export * from './ShortTextInput'
export * from './Textarea'