Skip to content
Merged
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
52 changes: 52 additions & 0 deletions app/components/demo/FaqTabsDemo.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { FaqTabs, type FaqTabsCategory } from "~/registry/blocks/faq-tabs";

const categories: FaqTabsCategory[] = [
{
value: "services",
label: "Services",
items: [
{
value: "services-offered",
question: "What services do you offer?",
answers: [
"We help teams design and ship Nuxt interfaces, registry blocks, and production-ready templates.",
"Your app keeps ownership of content, routes, analytics, and any data loading behavior.",
],
},
{
value: "startup-fit",
question: "Do you work with early-stage teams?",
answer: "Yes. The block works well for compact launch pages, pricing pages, and support sections where users need quick answers.",
},
],
},
{
value: "pricing",
label: "Pricing",
items: [
{
value: "project-cost",
question: "How much does a typical project cost?",
answer: "Pricing depends on scope, timeline, and required integration work. Most teams start with a focused design and implementation sprint.",
},
{
value: "retainers",
question: "Do you offer ongoing support?",
answer: "Yes. Retainers are a good fit when your team needs recurring design systems work, documentation, or registry maintenance.",
},
],
},
];
</script>

<template>
<div class="rounded-xl border bg-muted/20 p-4 sm:p-6">
<FaqTabs
title="Frequently asked questions"
description="Answer common questions with app-owned content while the block handles category navigation and disclosure UI."
:categories="categories"
class="py-0"
/>
</div>
</template>
19 changes: 19 additions & 0 deletions app/components/ui/accordion/Accordion.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { AccordionRootEmits, AccordionRootProps } from "reka-ui";
import { AccordionRoot, useForwardPropsEmits } from "reka-ui";

const props = defineProps<AccordionRootProps>();
const emits = defineEmits<AccordionRootEmits>();

const forwarded = useForwardPropsEmits(props, emits);
</script>

<template>
<AccordionRoot
v-slot="slotProps"
data-slot="accordion"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</AccordionRoot>
</template>
23 changes: 23 additions & 0 deletions app/components/ui/accordion/AccordionContent.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { AccordionContentProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { AccordionContent } from "reka-ui";
import { cn } from "@/lib/utils";

const props = defineProps<AccordionContentProps & { class?: HTMLAttributes["class"] }>();

const delegatedProps = reactiveOmit(props, "class");
</script>

<template>
<AccordionContent
data-slot="accordion-content"
v-bind="delegatedProps"
class="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
>
<div :class="cn('pt-0 pb-4', props.class)">
<slot />
</div>
</AccordionContent>
</template>
23 changes: 23 additions & 0 deletions app/components/ui/accordion/AccordionItem.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { AccordionItemProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { AccordionItem, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";

const props = defineProps<AccordionItemProps & { class?: HTMLAttributes["class"] }>();

const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>

<template>
<AccordionItem
v-slot="slotProps"
data-slot="accordion-item"
v-bind="forwardedProps"
:class="cn('border-b last:border-b-0', props.class)"
>
<slot v-bind="slotProps" />
</AccordionItem>
</template>
30 changes: 30 additions & 0 deletions app/components/ui/accordion/AccordionTrigger.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<script setup lang="ts">
import type { AccordionTriggerProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { ChevronDown } from "lucide-vue-next";
import { reactiveOmit } from "@vueuse/core";
import { AccordionHeader, AccordionTrigger } from "reka-ui";
import { cn } from "@/lib/utils";

const props = defineProps<AccordionTriggerProps & { class?: HTMLAttributes["class"] }>();

const delegatedProps = reactiveOmit(props, "class");
</script>

<template>
<AccordionHeader class="flex">
<AccordionTrigger
data-slot="accordion-trigger"
v-bind="delegatedProps"
:class="cn(
'focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
props.class,
)"
>
<slot />
<slot name="icon">
<ChevronDown class="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</slot>
</AccordionTrigger>
</AccordionHeader>
</template>
4 changes: 4 additions & 0 deletions app/components/ui/accordion/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { default as Accordion } from "./Accordion.vue";
export { default as AccordionContent } from "./AccordionContent.vue";
export { default as AccordionItem } from "./AccordionItem.vue";
export { default as AccordionTrigger } from "./AccordionTrigger.vue";
174 changes: 174 additions & 0 deletions app/registry/blocks/faq-tabs/FaqTabs.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { computed } from "vue";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import { cn } from "@/lib/utils";

export interface FaqTabsItem {
/** Stable id for the accordion item. */
value: string;
/** Visible question text. */
question: string;
/** Plain text answer for simple one-paragraph FAQs. */
answer?: string;
/** Plain text answer paragraphs when paragraph boundaries matter. */
answers?: string[];
}

export interface FaqTabsCategory {
/** Stable id for the tab value. */
value: string;
/** Visible tab label. */
label: string;
/** FAQ items supplied by the consuming app. */
items?: FaqTabsItem[];
}

export interface FaqTabsProps {
/** Optional section title. */
title?: string;
/** Optional supporting section copy. */
description?: string;
/** Categorized FAQ data supplied by the consuming app. */
categories?: FaqTabsCategory[] | null;
/** Initial selected category. Falls back to the first category with items. */
defaultCategoryValue?: string;
/** Accordion expansion behavior for each category. */
type?: "single" | "multiple";
/** Allows the open item to close when type is single. */
collapsible?: boolean;
/** Additional CSS classes for the section. */
class?: HTMLAttributes["class"];
}

const props = withDefaults(defineProps<FaqTabsProps>(), {
categories: () => [],
type: "multiple",
collapsible: false,
});

const categoriesWithItems = computed(() => {
return (props.categories ?? []).filter(category => (category.items?.length ?? 0) > 0);
});

const defaultCategory = computed(() => {
if (props.defaultCategoryValue && categoriesWithItems.value.some(category => category.value === props.defaultCategoryValue)) {
return props.defaultCategoryValue;
}

return categoriesWithItems.value[0]?.value;
});

const hasHeader = computed(() => Boolean(props.title || props.description));
const hasContent = computed(() => hasHeader.value || categoriesWithItems.value.length > 0);

function itemAnswers(item: FaqTabsItem) {
if (item.answers?.length) return item.answers;
if (item.answer) return [item.answer];
return [];
}
</script>

<template>
<section
v-if="hasContent"
data-slot="faq-tabs"
:class="cn('py-12 md:py-16', props.class)"
>
<div class="mx-auto w-full max-w-4xl px-4 sm:px-6 lg:px-8">
<div
v-if="hasHeader"
data-slot="faq-tabs-header"
class="max-w-2xl"
>
<h2
v-if="title"
data-slot="faq-tabs-title"
class="text-balance text-3xl font-semibold tracking-tight sm:text-4xl"
>
{{ title }}
</h2>
<p
v-if="description"
data-slot="faq-tabs-description"
class="mt-4 text-pretty text-base/7 text-muted-foreground"
>
{{ description }}
</p>
</div>

<Tabs
v-if="defaultCategory"
:default-value="defaultCategory"
:class="cn(hasHeader && 'mt-8')"
>
<TabsList class="flex h-auto flex-wrap justify-start gap-2 bg-transparent p-0">
<TabsTrigger
v-for="category in categoriesWithItems"
:key="category.value"
:value="category.value"
class="data-[state=active]:bg-muted hover:bg-muted/60 h-auto rounded-lg border px-3 py-2 text-sm text-muted-foreground shadow-none data-[state=active]:text-foreground data-[state=active]:shadow-none"
>
{{ category.label }}
</TabsTrigger>
</TabsList>

<TabsContent
v-for="(category, categoryIndex) in categoriesWithItems"
:key="category.value"
:value="category.value"
class="mt-5"
>
<Accordion
:type="type"
:collapsible="type === 'single' ? collapsible : undefined"
class="space-y-3"
>
<AccordionItem
v-for="(item, itemIndex) in category.items"
:key="item.value"
:value="item.value"
class="rounded-2xl border bg-card/70 px-4"
>
<AccordionTrigger class="py-4 text-base hover:no-underline">
{{ item.question }}
</AccordionTrigger>
<AccordionContent class="text-sm/6 text-muted-foreground">
<slot
v-if="$slots.answer"
name="answer"
:category="category"
:item="item"
:category-index="categoryIndex"
:item-index="itemIndex"
/>
<div
v-else
class="space-y-3"
>
<p
v-for="answer in itemAnswers(item)"
:key="answer"
>
{{ answer }}
</p>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</TabsContent>
</Tabs>
</div>
</section>
</template>
1 change: 1 addition & 0 deletions app/registry/blocks/faq-tabs/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as FaqTabs, type FaqTabsCategory, type FaqTabsItem, type FaqTabsProps } from "./FaqTabs.vue";
Loading
Loading