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: add related prompts list component #1680

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 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
@@ -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">
Copy link
Contributor

@annacv annacv Dec 17, 2024

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 a button, it can lead to accessibility issues, e.g. for screen readers
Or 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.

Copy link
Contributor Author

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 a button seems to work fine

Copy link
Contributor

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 an aria-pressed attributes for assistive technologies

<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" />
Copy link
Contributor

@annacv annacv Dec 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe colors should be customizable for customers

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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
},
Expand All @@ -64,39 +49,31 @@
type: Object as PropType<RelatedPrompt>,
required: true
},
nextQueryButtonClass: {
type: String,
default: 'x-button-outlined'
Comment on lines -67 to -69
Copy link
Contributor

Choose a reason for hiding this comment

The 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>
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ export const relatedPromptsXStoreModule: RelatedPromptsXStoreModule = {
state.relatedPrompts = products;
},
setSelectedPrompt(state, selectedPrompt) {
state.selectedPrompt = selectedPrompt;
if (state.selectedPrompt === selectedPrompt) {
state.selectedPrompt = -1;
} else {
state.selectedPrompt = selectedPrompt;
}
},
setSelectedQuery(state, selectedQuery) {
state.selectedQuery = selectedQuery;
Expand All @@ -44,6 +48,9 @@ export const relatedPromptsXStoreModule: RelatedPromptsXStoreModule = {
state.selectedQuery = -1;
state.selectedPrompt = -1;
state.relatedPrompts = [];
},
resetSelectedPrompt(state) {
state.selectedPrompt = -1;
}
},
actions: {
Expand Down
Loading
Loading