-
Notifications
You must be signed in to change notification settings - Fork 21
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: add related prompts list component #1680
base: main
Are you sure you want to change the base?
Changes from 2 commits
29e0c2c
b865f6b
b23fbb3
e6853b2
42440dd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,3 @@ | ||
export { default as RelatedPrompt } from './related-prompt.vue'; | ||
export { default as RelatedPromptsList } from './related-prompts-list.vue'; | ||
export { default as RelatedPromptsTagList } from './related-prompts-tag-list.vue'; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,60 +1,45 @@ | ||
<template> | ||
<div class="x-related-prompt" data-test="related-prompt"> | ||
<div class="x-related-prompt__info"> | ||
<slot name="header" :suggestionText="relatedPrompt.suggestionText"> | ||
{{ relatedPrompt.suggestionText }} | ||
</slot> | ||
<slot name="next-queries" :nextQueries="relatedPrompt.nextQueries"> | ||
<SlidingPanel :resetOnContentChange="false"> | ||
<div class="x-related-prompt__sliding-panel-content"> | ||
<button | ||
v-for="(nextQuery, index) in relatedPrompt.nextQueries" | ||
:key="index" | ||
@click="onClick(nextQuery)" | ||
:class="[ | ||
'x-button', | ||
{ 'x-selected': selectedNextQuery === nextQuery }, | ||
nextQueryButtonClass | ||
]" | ||
> | ||
<slot name="next-query" :nextQuery="nextQuery"> | ||
<span>{{ nextQuery }}</span> | ||
<CrossTinyIcon v-if="selectedNextQuery === nextQuery" class="x-icon" /> | ||
<PlusIcon v-else class="x-icon" /> | ||
</slot> | ||
</button> | ||
</div> | ||
</SlidingPanel> | ||
</slot> | ||
</div> | ||
<div class="x-related-prompt__query-preview"> | ||
<slot name="selected-query" :selectedQuery="selectedNextQuery"> | ||
{{ selectedNextQuery }} | ||
</slot> | ||
</div> | ||
</div> | ||
<button | ||
@click="toggleSuggestion(index)" | ||
class="x-related-prompt__button" | ||
:class="[{ 'x-related-prompt-selected__button': isSelected(index) }]" | ||
> | ||
<slot name="related-prompt-button-info"> | ||
<div class="x-related-prompt__button-info"> | ||
<span | ||
class="x-typewritter-initial" | ||
:class="[{ 'x-typewritter-animation': isVisible }]" | ||
:style="{ | ||
animationDelay: `${index * 0.4 + 0.05}s`, | ||
'--suggestion-text-length': relatedPrompt.suggestionText.length | ||
}" | ||
> | ||
{{ relatedPrompt.suggestionText }} | ||
</span> | ||
</div> | ||
<CrossTinyIcon v-if="isSelected(index)" class="x-icon-lg" /> | ||
<PlusIcon v-else class="x-icon-neutral-80 x-icon-lg" /> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe colors should be customizable for customers There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes, it was a mistake, that class should not be there |
||
</slot> | ||
</button> | ||
</template> | ||
<script lang="ts"> | ||
import { defineComponent, PropType, ref } from 'vue'; | ||
import { defineComponent, PropType } from 'vue'; | ||
import { RelatedPrompt } from '@empathyco/x-types'; | ||
import { relatedPromptsXModule } from '../x-module'; | ||
import CrossTinyIcon from '../../../components/icons/cross-tiny.vue'; | ||
import PlusIcon from '../../../components/icons/plus.vue'; | ||
import SlidingPanel from '../../../components/sliding-panel.vue'; | ||
import { use$x, useState } from '../../../composables/index'; | ||
|
||
/** | ||
* This component shows a suggested related prompt with the associated next queries. | ||
* It allows to select one of the next query and show it. | ||
* This component shows a suggested related prompt. | ||
* | ||
* It provide slots to customize the header, the next queries list, | ||
* the individual next query inside the list and the selected query. | ||
* It provides a slot to customize the related prompt button information. | ||
* | ||
* @public | ||
*/ | ||
export default defineComponent({ | ||
name: 'RelatedPrompt', | ||
components: { | ||
SlidingPanel, | ||
CrossTinyIcon, | ||
PlusIcon | ||
}, | ||
|
@@ -64,39 +49,31 @@ | |
type: Object as PropType<RelatedPrompt>, | ||
required: true | ||
}, | ||
nextQueryButtonClass: { | ||
type: String, | ||
default: 'x-button-outlined' | ||
Comment on lines
-67
to
-69
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we should keep this level of customization, so we can add a class to modify spacing or add background colors as per customer's requirements. |
||
isVisible: { | ||
type: Boolean, | ||
default: false | ||
}, | ||
index: { | ||
type: Number, | ||
required: true | ||
} | ||
}, | ||
setup(props) { | ||
const selectedNextQuery = ref(props.relatedPrompt.nextQueries[0]); | ||
setup() { | ||
const x = use$x(); | ||
const { selectedPrompt } = useState('relatedPrompts', ['selectedPrompt']); | ||
victorcg88 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
/** | ||
* Handles the click event on a next query button. | ||
* | ||
* @param nextQuery - The clicked next query. | ||
*/ | ||
function onClick(nextQuery: string): void { | ||
if (selectedNextQuery.value === nextQuery) { | ||
selectedNextQuery.value = ''; | ||
} else { | ||
selectedNextQuery.value = nextQuery; | ||
} | ||
} | ||
const toggleSuggestion = (index: number): void => { | ||
x.emit('UserSelectedARelatedPrompt', index); | ||
}; | ||
|
||
return { selectedNextQuery, onClick }; | ||
const isSelected = (index: number): boolean => selectedPrompt.value === index; | ||
victorcg88 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
return { | ||
isSelected, | ||
selectedPrompt, | ||
toggleSuggestion | ||
}; | ||
} | ||
}); | ||
</script> | ||
<style lang="css" scoped> | ||
.x-related-prompt__info { | ||
display: flex; | ||
flex-direction: column; | ||
} | ||
|
||
.x-related-prompt__sliding-panel-content { | ||
display: flex; | ||
gap: 8px; | ||
} | ||
</style> | ||
<style lang="css"></style> | ||
victorcg88 marked this conversation as resolved.
Show resolved
Hide resolved
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,212 @@ | ||
<template> | ||
<div> | ||
<template v-if="$slots.header"> | ||
<slot name="header" /> | ||
</template> | ||
<SlidingPanel | ||
:reset-on-content-change="true" | ||
:button-class="buttonClass" | ||
:scroll-container-class=" | ||
selectedPrompt === -1 ? 'desktop:x-sliding-panel-fade desktop:x-sliding-panel-fade-sm' : '' | ||
" | ||
> | ||
<template #sliding-panel-left-button> | ||
<slot name="sliding-panel-left-button" /> | ||
</template> | ||
|
||
<slot name="sliding-panel-content"> | ||
<div | ||
ref="slidingPanelContent" | ||
class="x-related-prompt__sliding-panel-content" | ||
:class="{ 'x-w-[calc(100%)]': selectedPrompt !== -1 }" | ||
> | ||
<div | ||
v-for="(suggestion, index) in relatedPrompts" | ||
:key="index" | ||
:style="{ | ||
animationDelay: `${index * 0.4 + 0.05}s` | ||
}" | ||
class="x-related-prompt x-staggered-initial" | ||
:class="[ | ||
{ 'x-staggered-animation': isVisible }, | ||
{ 'x-hidden': shouldHideButton(index) }, | ||
{ 'x-related-prompt-selected': isSelected(index) } | ||
]" | ||
data-test="related-prompt-item" | ||
> | ||
<slot name="related-prompt-button" v-bind="{ suggestion, index, isVisible }"> | ||
<RelatedPrompt :related-prompt="suggestion" :index="index" :is-visible="isVisible" /> | ||
</slot> | ||
</div> | ||
</div> | ||
</slot> | ||
|
||
<template #sliding-panel-right-button> | ||
<slot name="sliding-panel-right-button" /> | ||
</template> | ||
</SlidingPanel> | ||
</div> | ||
</template> | ||
<script lang="ts"> | ||
import { defineComponent, onMounted, onUnmounted, ref } from 'vue'; | ||
import SlidingPanel from '../../../components/sliding-panel.vue'; | ||
import { relatedPromptsXModule } from '../x-module'; | ||
import { useState } from '../../../composables/index'; | ||
import RelatedPrompt from './related-prompt.vue'; | ||
|
||
export default defineComponent({ | ||
name: 'RelatedPromptsTagList', | ||
xModule: relatedPromptsXModule.name, | ||
components: { RelatedPrompt, SlidingPanel }, | ||
props: { | ||
buttonClass: String | ||
}, | ||
setup() { | ||
const { relatedPrompts, selectedPrompt } = useState('relatedPrompts', [ | ||
'relatedPrompts', | ||
'selectedPrompt' | ||
]); | ||
|
||
const slidingPanelContent = ref<Element>(); | ||
const isVisible = ref(false); | ||
victorcg88 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
const observer = new IntersectionObserver(([entry]) => { | ||
isVisible.value = entry.isIntersecting; | ||
}); | ||
|
||
onMounted(() => { | ||
observer.observe(slidingPanelContent.value as Element); | ||
}); | ||
|
||
onUnmounted(() => { | ||
observer.disconnect(); | ||
}); | ||
|
||
const isSelected = (index: number): boolean => selectedPrompt.value === index; | ||
|
||
const shouldHideButton = (index: number): boolean => | ||
victorcg88 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
selectedPrompt.value !== -1 && selectedPrompt.value !== index; | ||
|
||
return { | ||
isVisible, | ||
shouldHideButton, | ||
isSelected, | ||
relatedPrompts, | ||
selectedPrompt, | ||
slidingPanelContent | ||
}; | ||
} | ||
}); | ||
</script> | ||
|
||
<style lang="css"> | ||
.x-related-prompt__sliding-panel-content { | ||
display: flex; | ||
gap: 8px; | ||
} | ||
|
||
.x-related-prompt { | ||
display: flex; | ||
flex-direction: column; | ||
border-radius: 12px; | ||
transition-property: all; | ||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); | ||
transition-duration: 500ms; | ||
min-height: 112px; | ||
height: 100%; | ||
width: 303px; | ||
} | ||
|
||
.x-related-prompt-selected { | ||
width: 100% !important; | ||
min-height: 0; | ||
border-bottom-right-radius: 0; | ||
border-bottom-left-radius: 0; | ||
|
||
&__button { | ||
width: 100% !important; | ||
} | ||
} | ||
|
||
.x-related-prompt__button { | ||
display: flex; | ||
flex-direction: row; | ||
gap: 12px; | ||
justify-content: space-between; | ||
align-items: start; | ||
text-align: start; | ||
padding: 16px; | ||
transition-property: all; | ||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); | ||
transition-duration: 500ms; | ||
flex-grow: 1; | ||
width: 303px; | ||
} | ||
|
||
.x-related-prompt__button-info { | ||
display: flex; | ||
min-height: 32px; | ||
} | ||
|
||
@media (max-width: 743px) { | ||
.x-related-prompt { | ||
width: 204px; | ||
&__button { | ||
width: 204px; | ||
} | ||
} | ||
} | ||
|
||
.x-no-scrollbar::-webkit-scrollbar { | ||
display: none; | ||
} | ||
|
||
.x-no-scrollbar { | ||
-ms-overflow-style: none; /* IE and Edge */ | ||
scrollbar-width: none; /* Firefox */ | ||
} | ||
|
||
.x-typewritter-initial { | ||
color: #0000; | ||
background: linear-gradient(-90deg, transparent 5px, #0000 0) 10px 0, | ||
linear-gradient(#575757 0 0) 0 0; | ||
background-size: 0 200%; | ||
-webkit-background-clip: padding-box, text; | ||
background-clip: padding-box, text; | ||
background-repeat: no-repeat; | ||
} | ||
|
||
.x-typewritter-animation { | ||
animation: typewritter calc(var(--suggestion-text-length) * 0.05s) | ||
steps(var(--suggestion-text-length)) forwards; | ||
} | ||
|
||
@keyframes typewritter { | ||
from { | ||
background-size: 0 200%; | ||
} | ||
to { | ||
background-size: calc(var(--suggestion-text-length) * 1ch) 200%; | ||
} | ||
} | ||
|
||
.x-staggered-initial { | ||
opacity: 0; | ||
transform: translateY(20px); | ||
} | ||
|
||
.x-staggered-animation { | ||
animation: fadeInUp 0.6s forwards; | ||
} | ||
|
||
@keyframes fadeInUp { | ||
from { | ||
opacity: 0; | ||
transform: translateY(20px); | ||
} | ||
to { | ||
opacity: 1; | ||
transform: translateY(0); | ||
} | ||
} | ||
</style> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hi, can we convert this div into a
span
?A
div
is not allowed inside abutton
, it can lead to accessibility issues, e.g. for screen readersOr maybe the right approach is not using a button tag above, otherwise, we can't grant that the content inside the slot is an HTML allowed one.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
with a
div
element instead of abutton
seems to work fineThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Then I would add there a
role=button
and anaria-pressed
attributes for assistive technologies