Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FEAT_CAROUSEL_MODE #1726

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { PictureChoiceBlock } from '@typebot.io/schemas/features/blocks/inputs/p
import { SwitchWithRelatedSettings } from '@/components/SwitchWithRelatedSettings'
import { defaultPictureChoiceOptions } from '@typebot.io/schemas/features/blocks/inputs/pictureChoice/constants'
import { useTranslate } from '@tolgee/react'
import { SwitchWithLabel } from '@/components/inputs/SwitchWithLabel'

type Props = {
options?: PictureChoiceBlock['options']
Expand All @@ -18,6 +19,8 @@ export const PictureChoiceSettings = ({ options, onOptionsChange }: Props) => {

const updateIsMultiple = (isMultipleChoice: boolean) =>
onOptionsChange({ ...options, isMultipleChoice })
const updateCarouselMode = (carouselMode: boolean) =>
onOptionsChange({ ...options, carouselMode })
const updateButtonLabel = (buttonLabel: string) =>
onOptionsChange({ ...options, buttonLabel })
const updateSaveVariable = (variable?: Variable) =>
Expand Down Expand Up @@ -98,6 +101,14 @@ export const PictureChoiceSettings = ({ options, onOptionsChange }: Props) => {
/>
</SwitchWithRelatedSettings>

<SwitchWithLabel
label={t('blocks.inputs.picture.itemSettings.carouselMode.label')}
initialValue={
options?.carouselMode ?? defaultPictureChoiceOptions.carouselMode
}
onCheckChange={updateCarouselMode}
/>

<SwitchWithRelatedSettings
label={t('blocks.inputs.picture.settings.dynamicItems.label')}
initialValue={
Expand Down
1 change: 1 addition & 0 deletions apps/builder/src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@
"blocks.inputs.payment.settings.successMessage.label": "Success message:",
"blocks.inputs.phone.settings.defaultCountry.label": "Default country:",
"blocks.inputs.phone.settings.international.placeholder.label": "International",
"blocks.inputs.picture.itemSettings.carouselMode.label": "Carousel mode",
"blocks.inputs.picture.itemSettings.image.change.label": "Change image",
"blocks.inputs.picture.itemSettings.image.label": "Image:",
"blocks.inputs.picture.itemSettings.image.pick.label": "Pick an image",
Expand Down
4 changes: 4 additions & 0 deletions apps/docs/editor/blocks/inputs/picture-choice.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,8 @@ The Picture choice input block allows you to offer your user predefined choices
/>
</Frame>

## Carousel mode

Enable carousel mode to view picture choices in a carousel. This is useful when you want to offer more than 4 choices.

For advanced configuration, check out the [Buttons block](./buttons) documentation. It works the same way.
11 changes: 11 additions & 0 deletions packages/embeds/js/src/assets/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,17 @@ pre {
transition: all 0.3s ease;
}

.typebot-carousel-button {
color: var(--typebot-button-color);
background-color: rgba(
var(--typebot-button-bg-rgb),
var(--typebot-button-opacity)
);
border-radius: var(--typebot-button-border-radius);
backdrop-filter: blur(var(--typebot-button-blur));
transition: all 0.3s ease;
}

.typebot-selectable {
border-width: var(--typebot-button-border-width);
border-color: rgba(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { For, createSignal } from 'solid-js'
import { PictureChoiceBlock } from '@typebot.io/schemas'
import { Carousel } from '@ark-ui/solid'

type Props = {
items: PictureChoiceBlock['items']
handleClick: (itemIndex: number) => void
onImageLoad: () => void
}

export const PictureCarousel = (props: Props) => {
const [currentIndex, setCurrentIndex] = createSignal(0)

return (
<>
<Carousel.Root
align="center"
loop={true}
orientation="horizontal"
index={currentIndex()}
onIndexChange={(details) => setCurrentIndex(details.index)}
class="relative w-full max-w-[300px] mx-auto"
>
<Carousel.Viewport class="overflow-hidden">
<Carousel.ItemGroup class="flex">
<For each={props.items}>
{(item, index) => (
<Carousel.Item index={index()} class="flex-shrink-0 w-full">
<button
on:click={() => props.handleClick(index())}
data-itemid={item.id}
class="w-full bg-white rounded-lg overflow-hidden focus:outline-none hover:shadow-lg transition-shadow duration-300 typebot-carousel-button"
>
<img
src={item.pictureSrc}
alt={item.title ?? `Picture ${index() + 1}`}
elementtiming={`Picture choice ${index() + 1}`}
fetchpriority={'high'}
class="w-full h-[200px] object-cover"
onLoad={props.onImageLoad}
/>
<div
class={
'flex flex-col gap-1 py-2 flex-shrink-0 px-4 w-full' +
(item.description ? ' items-start' : '')
}
>
<span class="font-semibold">{item.title}</span>
<span class="text-sm whitespace-pre-wrap text-left">
{item.description}
</span>
</div>
</button>
</Carousel.Item>
)}
</For>
</Carousel.ItemGroup>
</Carousel.Viewport>
<div class="absolute inset-0 flex justify-between items-center pointer-events-none">
<Carousel.PrevTrigger class="bg-white rounded-full p-2 shadow-md hover:bg-gray-100 ml-2 pointer-events-auto">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-6 h-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 19.5L8.25 12l7.5-7.5"
/>
</svg>
</Carousel.PrevTrigger>
<Carousel.NextTrigger class="bg-white rounded-full p-2 shadow-md hover:bg-gray-100 mr-2 pointer-events-auto">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-6 h-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8.25 4.5l7.5 7.5-7.5 7.5"
/>
</svg>
</Carousel.NextTrigger>
</div>
</Carousel.Root>
</>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { isMobile } from '@/utils/isMobileSignal'
import { isDefined, isNotEmpty, isSvgSrc } from '@typebot.io/lib/utils'
import { PictureChoiceBlock } from '@typebot.io/schemas/features/blocks/inputs/pictureChoice'
import { For, Show, createEffect, createSignal, onMount } from 'solid-js'
import { PictureCarousel } from './PictureCarousel'

type Props = {
defaultItems: PictureChoiceBlock['items']
Expand Down Expand Up @@ -56,58 +57,71 @@ export const SinglePictureChoice = (props: Props) => {
}

return (
<div class="flex flex-col gap-2 w-full">
<Show when={props.options?.isSearchable}>
<div class="flex items-end typebot-input w-full">
<SearchInput
ref={inputRef}
onInput={filterItems}
placeholder={props.options?.searchInputPlaceholder ?? ''}
onClear={() => setFilteredItems(props.defaultItems)}
<>
<Show when={props.options?.carouselMode === true}>
<div class="flex flex-col gap-2 w-full">
<PictureCarousel
items={filteredItems()}
handleClick={handleClick}
onImageLoad={onImageLoad}
/>
</div>
</Show>
<div
class={
'gap-2 flex flex-wrap justify-end' +
(props.options?.isSearchable
? ' overflow-y-scroll max-h-[464px] rounded-md'
: '')
}
>
<For each={filteredItems()}>
{(item, index) => (
<button
on:click={() => handleClick(index())}
data-itemid={item.id}
class={
'flex flex-col typebot-picture-button focus:outline-none filter hover:brightness-90 active:brightness-75 justify-between ' +
(isSvgSrc(item.pictureSrc) ? 'has-svg' : '')
}
>
<img
src={item.pictureSrc}
alt={item.title ?? `Picture ${index() + 1}`}
elementtiming={`Picture choice ${index() + 1}`}
fetchpriority={'high'}
class="m-auto"
onLoad={onImageLoad}
<Show when={props.options?.carouselMode === false}>
<div class="flex flex-col gap-2 w-full">
<Show when={props.options?.isSearchable}>
<div class="flex items-end typebot-input w-full">
<SearchInput
ref={inputRef}
onInput={filterItems}
placeholder={props.options?.searchInputPlaceholder ?? ''}
onClear={() => setFilteredItems(props.defaultItems)}
/>
<div
class={
'flex flex-col gap-1 py-2 flex-shrink-0 px-4 w-full' +
(item.description ? ' items-start' : '')
}
>
<span class="font-semibold">{item.title}</span>
<span class="text-sm whitespace-pre-wrap text-left">
{item.description}
</span>
</div>
</button>
)}
</For>
</div>
</div>
</div>
</Show>
<div
class={
'gap-2 flex flex-wrap justify-end' +
(props.options?.isSearchable
? ' overflow-y-scroll max-h-[464px] rounded-md'
: '')
}
>
<For each={filteredItems()}>
{(item, index) => (
<button
on:click={() => handleClick(index())}
data-itemid={item.id}
class={
'flex flex-col typebot-picture-button focus:outline-none filter hover:brightness-90 active:brightness-75 justify-between ' +
(isSvgSrc(item.pictureSrc) ? 'has-svg' : '')
}
>
<img
src={item.pictureSrc}
alt={item.title ?? `Picture ${index() + 1}`}
elementtiming={`Picture choice ${index() + 1}`}
fetchpriority={'high'}
class="m-auto"
onLoad={onImageLoad}
/>
<div
class={
'flex flex-col gap-1 py-2 flex-shrink-0 px-4 w-full' +
(item.description ? ' items-start' : '')
}
>
<span class="font-semibold">{item.title}</span>
<span class="text-sm whitespace-pre-wrap text-left">
{item.description}
</span>
</div>
</button>
)}
</For>
</div>
</div>
</Show>
</>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { PictureChoiceBlock } from './schema'
export const defaultPictureChoiceOptions = {
buttonLabel: defaultButtonLabel,
searchInputPlaceholder: 'Filter the options...',
carouselMode: false,
isMultipleChoice: false,
isSearchable: false,
dynamicItems: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { itemBaseSchemas } from '../../../items/shared'
export const pictureChoiceOptionsSchema = optionBaseSchema.merge(
z.object({
isMultipleChoice: z.boolean().optional(),
carouselMode: z.boolean().optional(),
isSearchable: z.boolean().optional(),
buttonLabel: z.string().optional(),
searchInputPlaceholder: z.string().optional(),
Expand Down
Loading