La sección de preguntas: una cabecera sobre un acordeón de P/R en columna estrecha. Compound (`.Item` repite) y —la regla al envolver un componente interactivo— un PROXY FINO del `Accordion` del canon: el block lee su API y la pasa tal cual, no reinventa el disclosure. `<Faq>` + `.Header` + `.List` + `.Item`. - `.List` **ES** el `Accordion`: toda su API pasa sin gate — `type` (single/multiple), `bind:value`, `collapsible`, `variant`, `size`. Defaults de FAQ: single, collapsible (el abierto se puede cerrar), outline. - `.Item` proxya `Accordion.Item > Header > Trigger`(snippet `question`) / `Content`(children) y autogenera el `value` (clave de estado) con `$props.id()` si no se pasa — la única conveniencia sobre el andamiaje. - El teclado (flechas, Home/End, Enter/Espacio), `aria-expanded` y `aria-controls` salen del `Accordion`; el block no toca la a11y del disclosure. `Container` estrecho (`md`) para una columna legible. Demo (`web/routes/blocks/faq/`): full-bleed + ruta `preview`, cinco preguntas en una columna, con la cola «¿aún tienes dudas?» que la app pone tras `.List`. Huecos a decisión del usuario (Gaps): la **lista estática 2/3 columnas** (6 de 7 en TW NO son acordeón, sino P/R siempre abiertas) — otra disposición, prop `layout` o hermano. Verificado en navegador: el acordeón abre/cierra, claro/oscuro × LTR/RTL. `blocks:check` verde (7 blocks) · `svelte-check` sin errores propios. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>alpha-0.1-sec-dom
parent
b59332132f
commit
54b8b838e7
@ -0,0 +1,52 @@
|
||||
# Faq
|
||||
|
||||
## Function
|
||||
|
||||
The questions section: a header over an accordion of question/answer pairs, in a
|
||||
narrow readable column. Usually near the end of a page, often after pricing.
|
||||
|
||||
## Composition map
|
||||
|
||||
| Part | Composes | Notes |
|
||||
| --- | --- | --- |
|
||||
| root | bare `<section>` + `Section` + `Container` (narrow) + `Stack` | places the header and the list |
|
||||
| `.Header` | `Box` + `Stack` | the app's section `Heading` + `Text` |
|
||||
| `.List` | `Accordion` | **is** the accordion — its whole API passes through |
|
||||
| `.Item` | `Accordion.Item` + `.Header` + `.Trigger` + `.Content` | thin proxy: `question` snippet → trigger, children → content |
|
||||
|
||||
## Why compound (a thin proxy)
|
||||
|
||||
Its `Item` **repeats**, so it earns a compound API like `feature-grid`. But the
|
||||
behaviour is the canon `Accordion`'s, not the block's — the block **reads that
|
||||
API and passes it straight through** (`type`, `bind:value`, `collapsible`,
|
||||
`variant`, `size` on `.List`) rather than reinventing disclosure. `.Item` is the
|
||||
one convenience: it spares the app the `Accordion.Item > Header > Trigger` /
|
||||
`Content` scaffolding and auto-generates the item `value` when omitted.
|
||||
|
||||
## Decisions
|
||||
|
||||
**2026-07-24 — reference floor** (dossier §P1: Tailwind Plus «FAQs 7» ·
|
||||
Untitled UI «FAQ 16» · Flowbite):
|
||||
|
||||
- **Adopted**: the accordion column (single, collapsible) — the interactive
|
||||
format, with the canon `Accordion`'s keyboard + ARIA for free.
|
||||
- **`.List` exposes the accordion tal cual**: `type='multiple'`, `bind:value`,
|
||||
`variant`… are the app's to set; the block does not gate them.
|
||||
- **Discarded**: reinventing the disclosure. `.Item` is a proxy, not a new
|
||||
behaviour.
|
||||
|
||||
## Demo
|
||||
|
||||
`web/routes/blocks/faq/` — the block full-bleed, a single-column accordion of
|
||||
questions. Mini-page in `FaqSite.svelte`, shared by both surfaces.
|
||||
|
||||
## Gaps
|
||||
|
||||
| Gap | Disposition |
|
||||
| --- | --- |
|
||||
| **Static 2/3-column list** (questions + answers laid out, no disclosure) | **scope-approval pending** — the dossier's FAQ gap: 6 of 7 in TW are NOT accordions. It is a different arrangement (a grid of Q&A, always open), so likely a `layout` prop or a sibling — a user decision, not a silent add |
|
||||
| **"Still have questions?" tail** (a CTA to support below the list) | **app-land** — the app puts a `Text` + `Link`/`Button` after `.List`; a dedicated slot is only worth it if a demo asks |
|
||||
|
||||
## Found while composing
|
||||
|
||||
- _(none yet — recorded here as they surface.)_
|
||||
@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
/** Faq.Header — the section title + description. The app supplies a `Heading`
|
||||
* (level 2) and a `Text`. */
|
||||
import { Box } from '$uix/eidos/components/box';
|
||||
import { Stack } from '$uix/eidos/components/stack';
|
||||
import type { FaqHeaderProps } from './types';
|
||||
|
||||
let { maxWidth = '48rem', children, ...rest }: FaqHeaderProps = $props();
|
||||
</script>
|
||||
|
||||
<Box {maxWidth} width="100%" {...rest}>
|
||||
<Stack gap={3}>
|
||||
{@render children?.()}
|
||||
</Stack>
|
||||
</Box>
|
||||
@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Faq.Item — one question, a thin proxy over `Accordion.Item`: the `question`
|
||||
* snippet becomes the trigger, the children become the content. The `value`
|
||||
* (the accordion's open-state key) is auto-generated when the app omits it.
|
||||
*/
|
||||
import { Accordion } from '$uix/eidos/components/accordion';
|
||||
import type { FaqItemProps } from './types';
|
||||
|
||||
let { value, disabled = false, question, children }: FaqItemProps = $props();
|
||||
|
||||
const uid = $props.id();
|
||||
const itemValue = $derived(value ?? `faq-${uid}`);
|
||||
</script>
|
||||
|
||||
<Accordion.Item value={itemValue} {disabled}>
|
||||
<Accordion.Header>
|
||||
<Accordion.Trigger>{@render question?.()}</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content>{@render children?.()}</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Faq.List — the canon `Accordion` itself. Its whole API passes through:
|
||||
* `type` (single / multiple), `value` (bindable), `collapsible`, `variant`,
|
||||
* `size`. Defaults suit an FAQ — single, collapsible (any open one can close),
|
||||
* outline.
|
||||
*/
|
||||
import { Accordion } from '$uix/eidos/components/accordion';
|
||||
import type { FaqListProps } from './types';
|
||||
|
||||
let {
|
||||
value = $bindable([]),
|
||||
type = 'single',
|
||||
collapsible = true,
|
||||
variant = 'outline',
|
||||
children,
|
||||
...rest
|
||||
}: FaqListProps = $props();
|
||||
</script>
|
||||
|
||||
<Accordion bind:value {type} {collapsible} {variant} {...rest}>
|
||||
{@render children?.()}
|
||||
</Accordion>
|
||||
@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Faq — a header over an accordion of questions. A narrow single column.
|
||||
* Compound: its `Item` repeats (the app maps over N questions), thinly
|
||||
* proxying the canon `Accordion` — the block reads that API, never reinvents
|
||||
* it (the whole accordion surface passes through `List`).
|
||||
*
|
||||
* B contract: composes canon components only — no `.css`, no morfo, no
|
||||
* strings. The block places (Section · Container · Accordion); the app
|
||||
* supplies the questions and answers.
|
||||
*/
|
||||
import { Section } from '$uix/eidos/components/section';
|
||||
import { Container } from '$uix/eidos/components/container';
|
||||
import { Stack } from '$uix/eidos/components/stack';
|
||||
import type { FaqProps } from './types';
|
||||
|
||||
let { container = 'md', size = 'lg', children, ...rest }: FaqProps = $props();
|
||||
</script>
|
||||
|
||||
<section {...rest}>
|
||||
<Section {size}>
|
||||
<Container size={container}>
|
||||
<Stack gap={8} align="stretch">
|
||||
{@render children?.()}
|
||||
</Stack>
|
||||
</Container>
|
||||
</Section>
|
||||
</section>
|
||||
@ -0,0 +1,45 @@
|
||||
// Faq — a header over an accordion of questions. Compound (its `Item` repeats),
|
||||
// a thin proxy over the canon `Accordion`.
|
||||
//
|
||||
// import { Faq } from '$blocks/faq';
|
||||
//
|
||||
// <Faq>
|
||||
// <Faq.Header>
|
||||
// <Heading level={2} align="center">Preguntas frecuentes</Heading>
|
||||
// </Faq.Header>
|
||||
// <Faq.List>
|
||||
// <Faq.Item>
|
||||
// {#snippet question()}¿Cómo empiezo?{/snippet}
|
||||
// Conecta el SDK y ya estás midiendo.
|
||||
// </Faq.Item>
|
||||
// </Faq.List>
|
||||
// </Faq>
|
||||
//
|
||||
// `Faq.List` IS the `Accordion` — its whole API (`type`, `bind:value`,
|
||||
// `collapsible`, `variant`, `size`) passes through. Every visible string is the
|
||||
// app's (B-7).
|
||||
import FaqComponent from './faq.svelte';
|
||||
import Header from './faq-header.svelte';
|
||||
import List from './faq-list.svelte';
|
||||
import Item from './faq-item.svelte';
|
||||
|
||||
type FaqNamespace = typeof FaqComponent & {
|
||||
Header: typeof Header;
|
||||
List: typeof List;
|
||||
Item: typeof Item;
|
||||
};
|
||||
|
||||
const Faq = FaqComponent as FaqNamespace;
|
||||
Faq.Header = Header;
|
||||
Faq.List = List;
|
||||
Faq.Item = Item;
|
||||
|
||||
export { Faq };
|
||||
export default Faq;
|
||||
|
||||
export type {
|
||||
FaqProps,
|
||||
FaqHeaderProps as HeaderProps,
|
||||
FaqListProps as ListProps,
|
||||
FaqItemProps as ItemProps
|
||||
} from './types';
|
||||
@ -0,0 +1,33 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import type { ContainerSize } from '$uix/eidos/components/container';
|
||||
import type { SectionSize } from '$uix/eidos/components/section';
|
||||
import type { BoxProps } from '$uix/eidos/components/box';
|
||||
import type { AccordionProps } from '$uix/eidos/components/accordion';
|
||||
|
||||
export type FaqProps = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
|
||||
/** Content measure — narrow by default for a single readable column. @default 'md' */
|
||||
container?: ContainerSize;
|
||||
/** Block-axis padding. Passed straight to `Section`. @default 'lg' */
|
||||
size?: SectionSize;
|
||||
/** `Header` and `List` in composition order. */
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
/** Wraps a `Box` (measure-cap, centered). `maxWidth` defaults to `48rem`. */
|
||||
export type FaqHeaderProps = BoxProps;
|
||||
|
||||
/** Wraps the canon `Accordion` — the whole accordion API (`type`, `value`
|
||||
* bindable, `collapsible`, `variant`, `size`) passes straight through. */
|
||||
export type FaqListProps = AccordionProps;
|
||||
|
||||
export type FaqItemProps = {
|
||||
/** Stable value for the accordion's open state. Auto-generated when omitted. */
|
||||
value?: string;
|
||||
/** Disable this question. @default false */
|
||||
disabled?: boolean;
|
||||
/** The question — the accordion trigger's label. */
|
||||
question?: Snippet;
|
||||
/** The answer — the accordion content. */
|
||||
children?: Snippet;
|
||||
};
|
||||
@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Faq demo. Full-bleed on the page; device widths from `./preview`.
|
||||
*/
|
||||
import { Stack } from '$uix/eidos/components/stack';
|
||||
import { Text } from '$uix/eidos/components/text';
|
||||
import { Code } from '$uix/eidos/components/code';
|
||||
import BlockDemo from '../_lib/BlockDemo.svelte';
|
||||
import FaqSite from './FaqSite.svelte';
|
||||
import DocRow from '../_lib/DocRow.svelte';
|
||||
|
||||
const previewSrc = '/blocks/faq/preview';
|
||||
</script>
|
||||
|
||||
<BlockDemo
|
||||
name="FAQ"
|
||||
slug="$blocks/faq"
|
||||
{previewSrc}
|
||||
previewHeight="640px"
|
||||
meta={[
|
||||
{ key: 'compone', value: 'Section · Container · Accordion · Heading · Text' },
|
||||
{ key: 'tipo', value: 'compound (proxy fino de Accordion)' },
|
||||
{ key: 'partes', value: 'Header · List · Item' },
|
||||
{ key: 'strings propias', value: '0' }
|
||||
]}
|
||||
>
|
||||
{#snippet preview()}
|
||||
<FaqSite />
|
||||
{/snippet}
|
||||
|
||||
{#snippet lede()}
|
||||
La sección de preguntas: una cabecera sobre un acordeón de preguntas y respuestas, en columna
|
||||
estrecha. Es un <strong>proxy fino</strong> del <Code>Accordion</Code> del canon — el block lee
|
||||
su API y la pasa tal cual, no reinventa el disclosure.
|
||||
{/snippet}
|
||||
|
||||
{#snippet composition()}
|
||||
<Stack gap={4}>
|
||||
<Text color="muted">
|
||||
El comportamiento es del <Code>Accordion</Code>; el block solo coloca y ahorra andamiaje.
|
||||
</Text>
|
||||
<Stack gap={3}>
|
||||
<DocRow term=".List">
|
||||
<strong>Es</strong> el <Code>Accordion</Code>: toda su API pasa tal cual —
|
||||
<Code>type</Code> (single/multiple), <Code>bind:value</Code>, <Code>collapsible</Code>,
|
||||
<Code>variant</Code>, <Code>size</Code>. Por defecto: single, collapsible, outline.
|
||||
</DocRow>
|
||||
<DocRow term=".Item">
|
||||
Proxy de <Code>Accordion.Item</Code>: el snippet <Code>question</Code> es el trigger, los
|
||||
children son el contenido. El <Code>value</Code> (clave de estado) se autogenera si no lo
|
||||
pasas.
|
||||
</DocRow>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{/snippet}
|
||||
|
||||
{#snippet api()}
|
||||
<Stack gap={3}>
|
||||
<DocRow term="Faq">
|
||||
<Code>container</Code> (def. <Code>md</Code>, estrecho) · <Code>size</Code> (def.
|
||||
<Code>lg</Code>).
|
||||
</DocRow>
|
||||
<DocRow term=".List">
|
||||
Todo el API del <Code>Accordion</Code>: <Code>type</Code>, <Code>bind:value</Code>,
|
||||
<Code>collapsible</Code>, <Code>variant</Code>, <Code>size</Code>.
|
||||
</DocRow>
|
||||
<DocRow term=".Item">
|
||||
<Code>value</Code> (autogenerado) · <Code>disabled</Code> · <Code>question</Code> (snippet) +
|
||||
children (la respuesta).
|
||||
</DocRow>
|
||||
</Stack>
|
||||
{/snippet}
|
||||
|
||||
{#snippet a11y()}
|
||||
<Stack gap={3}>
|
||||
<DocRow term="Disclosure">
|
||||
El teclado (flechas, Home/End, Enter/Espacio), <Code>aria-expanded</Code> y
|
||||
<Code>aria-controls</Code> salen del <Code>Accordion</Code> del canon — el block no toca la
|
||||
a11y del disclosure.
|
||||
</DocRow>
|
||||
<DocRow term="Encabezados">
|
||||
La sección un <Code>h2</Code>; cada pregunta un <Code>h3</Code> (nivel del
|
||||
<Code>Accordion.Item</Code>).
|
||||
</DocRow>
|
||||
</Stack>
|
||||
{/snippet}
|
||||
|
||||
{#snippet gaps()}
|
||||
<Stack gap={3}>
|
||||
<DocRow term="Lista estática 2/3 columnas">
|
||||
<strong>Scope-approval pendiente</strong> — la brecha del dossier: 6 de 7 en TW NO son
|
||||
acordeón, sino una rejilla de P/R siempre abiertas. Otra disposición → prop
|
||||
<Code>layout</Code> o hermano.
|
||||
</DocRow>
|
||||
<DocRow term="Cola «¿Aún tienes dudas?»">
|
||||
<strong>App-land</strong> — la app pone un <Code>Text</Code> + <Code>Link</Code>/<Code>Button</Code>
|
||||
tras <Code>.List</Code> (como en esta demo); un slot propio solo si una demo lo pide.
|
||||
</DocRow>
|
||||
</Stack>
|
||||
{/snippet}
|
||||
|
||||
{#snippet notes()}
|
||||
<Stack gap={3}>
|
||||
<DocRow term="Proxy, no reinvención">
|
||||
El disclosure es del <Code>Accordion</Code>. El block no reimplementa apertura ni teclado —
|
||||
pasa la API tal cual. La única conveniencia es <Code>.Item</Code>, que ahorra el andamiaje
|
||||
<Code>Item > Header > Trigger</Code> / <Code>Content</Code>.
|
||||
</DocRow>
|
||||
</Stack>
|
||||
{/snippet}
|
||||
</BlockDemo>
|
||||
@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The faq mini-page: a single-column accordion of questions, plus a page floor
|
||||
* with a support tail.
|
||||
*/
|
||||
import { Faq } from '$blocks/faq';
|
||||
import { Section } from '$uix/eidos/components/section';
|
||||
import { Container } from '$uix/eidos/components/container';
|
||||
import { Stack } from '$uix/eidos/components/stack';
|
||||
import { Heading } from '$uix/eidos/components/heading';
|
||||
import { Text } from '$uix/eidos/components/text';
|
||||
import { Link } from '$uix/eidos/components/link';
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
q: '¿Cómo empiezo a medir?',
|
||||
a: 'Instalas un único SDK, envías tu primer evento y ya lo ves en el panel. Sin configurar pipelines ni esperar a un procesamiento nocturno.'
|
||||
},
|
||||
{
|
||||
q: '¿Cobráis por asiento?',
|
||||
a: 'No. El plan se elige por volumen de eventos, no por número de personas, así que puedes invitar a todo el equipo sin que suba la factura.'
|
||||
},
|
||||
{
|
||||
q: '¿Se muestrean los eventos?',
|
||||
a: 'Nunca. Guardamos todos los eventos, así que los números cuadran con tu base de datos hasta el último registro.'
|
||||
},
|
||||
{
|
||||
q: '¿Puedo consultar periodos antiguos sin reprocesar?',
|
||||
a: 'Sí. El motor mantiene los agregados al día, así que cualquier ventana temporal responde al instante, sin recomputar cohortes.'
|
||||
},
|
||||
{
|
||||
q: '¿Ofrecéis SSO y auditoría?',
|
||||
a: 'En el plan Enterprise: SAML, SCIM, registro de auditoría y residencia de datos, listos para tu equipo de seguridad.'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<Faq>
|
||||
<Faq.Header>
|
||||
<Heading level={2} align="center">Preguntas frecuentes</Heading>
|
||||
<Text size="lg" color="muted" align="center">
|
||||
Lo que casi todo el mundo pregunta antes de conectar la primera fuente de eventos.
|
||||
</Text>
|
||||
</Faq.Header>
|
||||
|
||||
<Faq.List>
|
||||
{#each faqs as item (item.q)}
|
||||
<Faq.Item>
|
||||
{#snippet question()}{item.q}{/snippet}
|
||||
{item.a}
|
||||
</Faq.Item>
|
||||
{/each}
|
||||
</Faq.List>
|
||||
</Faq>
|
||||
|
||||
<!-- A floor so the page reads as a page — the support tail. -->
|
||||
<Section size="lg">
|
||||
<Container size="md">
|
||||
<Stack gap={2} align="center">
|
||||
<Text weight="medium" align="center">¿Te queda alguna duda?</Text>
|
||||
<Text color="muted" align="center">
|
||||
Escríbenos y te respondemos el mismo día. <Link href="#soporte">Hablar con soporte</Link>.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Section>
|
||||
@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The preview is its OWN page — `@` resets the layout. Axes arrive via the URL
|
||||
* so the demo's iframe can drive them.
|
||||
*/
|
||||
import { page } from '$app/state';
|
||||
import BootUix, { type BootLanguage } from '../../_lib/BootUix.svelte';
|
||||
import '@/uix/eidos/index.css';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const mode = $derived(page.url.searchParams.get('mode') === 'dark' ? 'dark' : 'light');
|
||||
const dir = $derived(page.url.searchParams.get('dir') === 'rtl' ? 'rtl' : 'ltr');
|
||||
const language = $derived((page.url.searchParams.get('lang') ?? 'es') as BootLanguage);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
</svelte:head>
|
||||
|
||||
<BootUix {mode} {dir} {language}>
|
||||
<div
|
||||
data-theme={mode}
|
||||
data-mode={mode}
|
||||
{dir}
|
||||
style="min-block-size: 100dvh; background: var(--color-surface-default); color: var(--color-content-primary);"
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</BootUix>
|
||||
@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Standalone page for the faq mini-site — same component the demo renders
|
||||
* inline, served as its OWN document for the device-width frame.
|
||||
*/
|
||||
import FaqSite from '../FaqSite.svelte';
|
||||
</script>
|
||||
|
||||
<FaqSite />
|
||||
Loading…
Reference in new issue