feat(eidos): IconButton primitive + Lote 0 iconOnly->IconButton cascade

Lote 0 of the component-coherence audit (FASE 2):

- New IconButton primitive (composes Button: inherits variant/size/intent/
  color/rounded/shape/loading + Button's morfo + sema, no new morfo;
  aria-label required; glyph as children). New svg/ glyphs: SvgX, SvgPlus,
  SvgMinus.
- Button: rounded='full' 9999px -> var(--radius-full) (theme-driven radius,
  value-identical).
- Migrate icon-only Button consumers to IconButton: carousel prev/next
  (pilot), tree-grid expand, table row-detail, search-field clear, combobox
  clear. Pattern: glyph->children, label->aria-label, drop iconOnly.
- carousel.css: fix arrow positioning (specificity vs Button's base
  position:relative, exposed by the IconButton import-order flip) + the
  press-squeeze jump (centre via the translate property so the contact
  press transform:scale composes instead of replacing the centring).
- Doc: COMPONENT_COHERENCE_AUDIT - indicator-chevron vs discrete IconButton
  distinction (tree-view/accordion stay out); 5F width closed (full-width);
  Lote 0 hand-off + pending select-family faults.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent e16c169f37
commit 189932d939

@ -151,6 +151,21 @@ tienen chrome propio coherente, se quedan bespoke-tokenizados.
| radio-cards / rating-group | card/item | commit | bespoke (tokenizado) |
| tree-view / tree-grid | node | commit/emerge | bespoke (tokenizado) |
> **Indicador vs botón discreto (chevron de expand) — verificado 2026-06-18.** El
> chevron de expand es **IconButton SOLO cuando es un botón DISCRETO** (`tree-grid`
> `expand-trigger`: un botoncito aparte en la fila, que compone `<Button iconOnly>`
> → migrado a `<IconButton>`). Cuando es un **indicador DECORATIVO** —`<SvgChevron>`
> que rota por `data-state` dentro de una **superficie clicable mayor**— **NO es
> IconButton**:
> - `tree-view` `BranchIndicator`: chevron suelto; el control es **toda la rama**.
> - `accordion`: `<span data-accordion-indicator aria-hidden><SvgChevron></span>`
> dentro de la cabecera; el control es **toda la `<Accordion.Trigger>`**.
>
> Mismo glifo (`SvgChevron`) en los tres → coherencia de glifo. Lo que cambia es el
> **modelo de interacción** (toggle de superficie entera vs botón discreto). La
> regla: *chevron como indicador de estado ≠ botón icon-only*. Por eso tree-view y
> accordion quedan fuera de la cascada IconButton, correctamente.
---
## DIMENSIÓN 2 — Literales que se saltan el tema
@ -323,8 +338,14 @@ desproporcionados — un `12/31/2026` no necesita la columna entera; los pickers
decidir — mantener un ancho por defecto razonable (no colapsar vacío) + `block`,
o conservar 100% (convención de columna de formulario).
**Decisión de diseño** (implicación de layout de formularios): ¿alineamos TODA la
familia al modelo `block`, o solo los de contenido fijo (date/time/color/pin)?
**Decisión de diseño — CERRADA (2026-06-18): full-width para TODA la familia.**
El usuario rechazó `max-content`/`block` para los fields. La anchura la gobierna el
**contenedor** (columna de formulario, `Box`/`Stack` con `max-width`), no el campo —
convención dominante (Chakra/Bootstrap/Radix Themes/shadcn = full-width por defecto;
MUI con `fullWidth` opt-in es el outlier). `inline-size: 100%` se queda en toda la
familia, sin opt-out por campo. **Sin acción de código**: el estado actual ya es
full-width homogéneo (verificado en el audit de anchura del 2026-06-18; color-field
era la única excepción —un `fit-content` temporal— ya revertido).
### 5B — Nits de API (🟡)
@ -392,9 +413,9 @@ familia al modelo `block`, o solo los de contenido fijo (date/time/color/pin)?
activo en date/time/color-field, unificar el fondo de resalte a un token; **quitar
`border-color`+`box-shadow` de la transición del input** (elimina el flicker del
borde al incrementar por teclado).
- **Modelo de anchura (5F)**: alinear la familia field al patrón `block` — fields
de contenido fijo (date/time/color/pin) → `inline-size: max-content` + `block` para
100%. _(Pendiente decisión: ¿toda la familia o solo contenido-fijo?)_
- **Modelo de anchura (5F)**: ✅ CERRADO (2026-06-18) — **full-width para toda la
familia** (container-driven). Sin acción de código: el estado actual ya es
homogéneo.
- **Nits API (5B)**: image-picker default `md`, `switch` variant colgante,
renombrar `link` `'default'`, normalizar variants de code/code-block/kbd.
@ -405,6 +426,63 @@ paridad visual (hover/focus/disabled) + a11y (foco/teclado) en selects migrados.
---
## Estado de ejecución — hand-off 2026-06-18
### Lote 0 — IconButton + cascada `iconOnly`→`IconButton`
**Hecho + verificado:**
- **Primitivos**: `svg/SvgX` · `SvgPlus` · `SvgMinus` (animables, `pathLength`).
**`IconButton`** (`components/icon-button/`): compone `<Button>` (hereda
variant/size/intent/color/rounded/shape/loading + morfo + sema de Button; **sin
morfo nuevo**), `aria-label` **obligatorio**, glifo como `children`, `iconOnly`
interno. Button: `rounded='full'` `9999px` → `var(--radius-full)`.
- **Migrados** (patrón: glifo→`children`, label→`aria-label`, fuera `iconOnly`):
carousel (prev/next, piloto) + los **4 de ①** — `tree-grid-expand`,
`table-row-detail`, `search-field-clear`, `combobox-clear`. Compile 0-nuevos +
medidos en navegador.
**Gotchas (releer antes de seguir la cascada):**
- **Orden de carga CSS**: migrar `Button`→`IconButton` mete una indirección de
import (consumidor→IconButton→Button) que **voltea el orden de carga** → rompe
empates de especificidad con `[data-button]{position:relative}` (las flechas del
carousel se descolocaron, `relative` ganó a `absolute`). **Fix**: subir la
especificidad del posicionado del consumidor con un ancestro (`[data-carousel]
[data-…-trigger]`, 0,2,0 > 0,1,0). Los clears del campo son **flex-items** → sin
empate.
- **press-squeeze vs centrado por transform**: `contact-activate` (lo dispara TODO
Button) aplica `press-squeeze` (`transform: scale(0.96)`), que **reemplaza** un
`transform: translateY(-50%)` de centrado → **salto** al pulsar. **Fix**: centrar
con la **propiedad `translate`** (compone con el `scale` en vez de borrarlo);
quitar overrides `:active`.
**Pendiente del Lote 0:**
- **② condicionales** (NO icon-only puro): `clipboard-trigger` (default texto
"Copy") y `dropdown-menu-trigger` (trigger genérico) reenvían `iconOnly` opcional
→ no se migran de golpe. **Decisión**: render condicional `IconButton`/`Button`, o
se quedan en Button. **Bloquea el cierre** (quitar `iconOnly` del API público de
Button).
- **corner-shape** (dim 4 / Lote D): Button declara `shape` + emite `data-shape`
pero `button.css` **NO lo materializa** (0 reglas `corner-shape`) → todo
botón/IconButton es **solo redondo-arco**. `SHAPE_ENGINE_RFC §6` lo difiere
(adoptarlo = adelantar — decisión del usuario).
- **tree-view + accordion** quedan FUERA (chevron = indicador, no botón discreto —
ver la nota en Dimensión 1).
### Faults de los selects (reportados por el usuario — siguiente bloque)
| Componente | Faults |
|---|---|
| **Combobox** | (a) flecha abrir/cerrar es semántica → **emitir evento** · (b) popup **sin padding inferior** · (c) scroll → usar **`ScrollArea`** · (d) **bug**: clic en flecha con popup abierto lo cierra y **reabre** · (e) tipografía md=sm (**no 1:1** — combobox no entró en el sweep de fuente) · (f) popup **pegado al input** (solapa) · (g) **elevación** no "un paso más que el disparador" (escalera canonizada `default<raised<muted<overlay`) |
| **SearchField** | tipografía = la del input pero **menor peso** · scroll → **`ScrollArea`** · footer poco diferenciado → **subir un paso** la escala |
| **Select** | item seleccionado: **borde y resaltado muy pegados** → espacio coherente con tokens del tema |
| **Langs (REGLA transversal)** | **cero hardcodes** — las strings (aria-labels…) van en el **morfo** del componente vía langs. Incluye **corregir los `aria-label` hardcodeados** que dejó la cascada (tree-grid/table/search-field: "Toggle row"/etc. → langs) |
Orden: **combobox → search-field → select → barrido de langs**, uno a uno verificando.
`combobox-arrow`/`content`/`viewport` son thin-wrappers del soma (la lógica de
abrir/cerrar + el scroll viven en soma; el styling en `combobox.css`).
---
## Apéndice — precedentes / plantillas a reutilizar
- **Button-consumer**: `components/carousel/carousel-prev-trigger.svelte`.

@ -199,7 +199,7 @@
[data-button][data-rounded='md'] { --_button-radius: var(--button-radius-md); }
[data-button][data-rounded='lg'] { --_button-radius: var(--button-radius-lg); }
[data-button][data-rounded='xl'] { --_button-radius: var(--button-radius-xl); }
[data-button][data-rounded='full'] { --_button-radius: 9999px; }
[data-button][data-rounded='full'] { --_button-radius: var(--radius-full); }
/* ── Block ─────────────────────────────────────────────────────────────── */

@ -1,24 +1,23 @@
<script lang="ts">
/**
* Eidos `Carousel.NextTrigger` — symmetric mirror of `PrevTrigger`.
* Right-pointing chevron, identical visual API.
* Right-pointing chevron, identical visual API, rendered as `<IconButton>`.
*/
import * as Carousel from '$soma/components/carousel';
import { Button } from '$uix/eidos/components/button';
import { IconButton } from '$uix/eidos/components/icon-button';
import { SvgChevron } from '$uix/eidos/components/svg';
import { getCarouselEidosContext } from './carousel-context';
import type { CarouselNextTriggerProps } from './types';
let {
children,
child: outerChild,
variant = 'ghost',
size,
rounded = 'full',
intent,
color,
iconOnly = true,
icon: customIcon,
'aria-label': ariaLabel = 'Next slide',
...rest
}: CarouselNextTriggerProps = $props();
@ -28,27 +27,14 @@
const chevronDir = $derived(carousel?.orientation === 'vertical' ? 'down' : 'right');
</script>
{#snippet defaultIcon()}
<SvgChevron direction={chevronDir} />
{/snippet}
<Carousel.NextTrigger {...rest}>
{#snippet child({ props })}
{#if outerChild}
{@render outerChild({ props })}
{:else}
<Button
{...props}
{variant}
size={resolvedSize}
{rounded}
{intent}
{color}
{iconOnly}
icon={customIcon ?? defaultIcon}
>
{#if children}{@render children()}{:else}Next slide{/if}
</Button>
<IconButton {...props} {variant} size={resolvedSize} {rounded} {intent} {color} aria-label={ariaLabel}>
{#if customIcon}{@render customIcon()}{:else}<SvgChevron direction={chevronDir} />{/if}
</IconButton>
{/if}
{/snippet}
</Carousel.NextTrigger>

@ -1,29 +1,26 @@
<script lang="ts">
/**
* Eidos `Carousel.PrevTrigger` — wraps soma's PrevTrigger and renders
* it as an eidos `<Button>` via soma's `child` snippet. Default chrome
* is `iconOnly variant='ghost' rounded='full'` with a left-pointing
* chevron glyph. Consumers can override every visual prop inline; passing
* a `child` snippet bypasses Button entirely.
*
* Size cascades from the Carousel root unless overridden.
* Eidos `Carousel.PrevTrigger` — wraps soma's PrevTrigger and renders it
* as an eidos `<IconButton>` via soma's `child` snippet. Default chrome is
* `variant='ghost' rounded='full'` with a left-pointing chevron glyph.
* Consumers override visual props inline; a `child` snippet bypasses
* IconButton entirely. Size cascades from the Carousel root.
*/
import * as Carousel from '$soma/components/carousel';
import { Button } from '$uix/eidos/components/button';
import { IconButton } from '$uix/eidos/components/icon-button';
import { SvgChevron } from '$uix/eidos/components/svg';
import { getCarouselEidosContext } from './carousel-context';
import type { CarouselPrevTriggerProps } from './types';
let {
children,
child: outerChild,
variant = 'ghost',
size,
rounded = 'full',
intent,
color,
iconOnly = true,
icon: customIcon,
'aria-label': ariaLabel = 'Previous slide',
...rest
}: CarouselPrevTriggerProps = $props();
@ -33,27 +30,14 @@
const chevronDir = $derived(carousel?.orientation === 'vertical' ? 'up' : 'left');
</script>
{#snippet defaultIcon()}
<SvgChevron direction={chevronDir} />
{/snippet}
<Carousel.PrevTrigger {...rest}>
{#snippet child({ props })}
{#if outerChild}
{@render outerChild({ props })}
{:else}
<Button
{...props}
{variant}
size={resolvedSize}
{rounded}
{intent}
{color}
{iconOnly}
icon={customIcon ?? defaultIcon}
>
{#if children}{@render children()}{:else}Previous slide{/if}
</Button>
<IconButton {...props} {variant} size={resolvedSize} {rounded} {intent} {color} aria-label={ariaLabel}>
{#if customIcon}{@render customIcon()}{:else}<SvgChevron direction={chevronDir} />{/if}
</IconButton>
{/if}
{/snippet}
</Carousel.PrevTrigger>

@ -231,19 +231,28 @@
flex: 0 0 100%;
}
/* ── Triggers (Button consumer pattern) ─
/* ── Triggers (IconButton consumer pattern) ─
*
* The triggers are rendered as <Button iconOnly variant='ghost'
* rounded='full'>. The recipe selectors below target the `[data-carousel-
* prev-trigger]` / `[data-carousel-next-trigger]` attrs landed on the
* underlying button. Default placement is absolute over the viewport edges;
* consumers can override with custom layout.
* The triggers are rendered as <IconButton variant='ghost' rounded='full'>.
* The selectors below target the `[data-carousel-prev-trigger]` /
* `[data-carousel-next-trigger]` attrs landed on the underlying button.
* Default placement is absolute over the viewport edges.
*
* The `[data-carousel]` ancestor is REQUIRED: it lifts the positioning to
* specificity (0,2,0) so it beats Button's base `[data-button]{position:
* relative}` (0,1,0 — Button's own spinner anchor) regardless of CSS load
* order. A bare `[data-carousel-prev-trigger]` ties and loses whenever
* button.css loads later (which the IconButton import chain can cause).
*/
[data-carousel-prev-trigger],
[data-carousel-next-trigger] {
[data-carousel] [data-carousel-prev-trigger],
[data-carousel] [data-carousel-next-trigger] {
position: absolute;
inset-block-start: 50%;
transform: translateY(-50%);
/* Centre via the `translate` PROPERTY (not `transform`) so the press
feedback — press-squeeze's `transform: scale` — COMPOSES with the
centring instead of replacing it. With `transform` the scale wiped the
-50% offset and the trigger dropped half its height on every press. */
translate: 0 -50%;
z-index: 1;
}
@ -261,31 +270,11 @@
cursor: not-allowed;
}
/*
* Compose the Button `:active` press feedback with the trigger's
* centering transform. Without these overrides Button's recipe sets
* `transform: translateY(1px)` on press which REPLACES our centering
* `translateY(-50%)` (or `translateX(-50%)` in vertical), making the
* trigger jump entirely off its anchor when clicked. The override
* keeps the centering AND adds the 1px press shift on the same axis.
*/
[data-carousel-prev-trigger]:active:not([data-disabled]):not([data-loading]),
[data-carousel-next-trigger]:active:not([data-disabled]):not([data-loading]) {
transform: translateY(calc(-50% + 1px));
}
[data-carousel][data-orientation='vertical']
[data-carousel-prev-trigger]:active:not([data-disabled]):not([data-loading]),
[data-carousel][data-orientation='vertical']
[data-carousel-next-trigger]:active:not([data-disabled]):not([data-loading]) {
transform: translateX(calc(-50% + 1px));
}
/* Vertical orientation flips the triggers to top/bottom. */
[data-carousel][data-orientation='vertical'] [data-carousel-prev-trigger] {
inset-block-start: var(--_carousel-trigger-offset);
inset-inline-start: 50%;
transform: translateX(-50%);
translate: -50% 0;
}
[data-carousel][data-orientation='vertical'] [data-carousel-next-trigger] {
@ -293,7 +282,7 @@
inset-block-end: var(--_carousel-trigger-offset);
inset-inline-end: auto;
inset-inline-start: 50%;
transform: translateX(-50%);
translate: -50% 0;
}
/* ── IndicatorGroup ─ */

@ -1,3 +1,4 @@
import type { Snippet } from 'svelte';
import type {
ColorRole,
ControlVariant,
@ -108,10 +109,10 @@ type SharedTriggerVisualProps = {
intent?: EidosButtonProps['intent'];
/** Hierarchical color override (only applies under intent='neutral'). */
color?: EidosButtonProps['color'];
/** Render as icon-only (default). @default true */
iconOnly?: boolean;
/** Leading icon snippet (replaces default arrow glyph). */
icon?: EidosButtonProps['icon'];
/** Accessible name for the icon-only trigger. @default 'Previous slide' / 'Next slide' */
'aria-label'?: string;
/** Override the default arrow glyph. */
icon?: Snippet;
};
export type CarouselPrevTriggerProps = PrevTriggerProps & SharedTriggerVisualProps;

@ -1,27 +1,27 @@
<script lang="ts">
/**
* Eidos `Combobox.ClearButton` — icon-only X that clears the input.
* Renders an eidos `<Button>` via soma's `child` snippet (chrome,
* Eidos `Combobox.ClearButton` — icon-only X that clears the input,
* rendered as an eidos `<IconButton>` via soma's `child` snippet (chrome,
* focus ring, hover, size all come from button.css). Default
* `variant='ghost' size='xs' iconOnly` — the typical subtle clear
* affordance.
* `variant='ghost' size='xs'` — the typical subtle clear affordance.
*
* Visibility (hidden when the combobox is empty) is still handled by
* `combobox.css` via the `[data-combobox][data-empty]` ancestor rule.
* Visibility (hidden when the combobox is empty) is handled by
* `combobox.css` via the `[data-combobox][data-empty]` ancestor.
*
* Composition: callers can pass their own `children` snippet (custom
* glyph) or `child` snippet (full bypass of Button).
* Composition: callers can pass a custom glyph via `children`, or a
* `child` snippet (full bypass of IconButton).
*/
import * as Combobox from '$soma/components/combobox';
import { Button } from '$uix/eidos/components/button';
import { IconButton } from '$uix/eidos/components/icon-button';
import type { ComboboxClearButtonProps } from './types';
let {
children,
children: customGlyph,
child: outerChild,
variant = 'ghost',
size = 'xs',
rounded,
'aria-label': ariaLabel = 'Clear',
...rest
}: ComboboxClearButtonProps = $props();
</script>
@ -31,17 +31,18 @@
{#if outerChild}
{@render outerChild({ props })}
{:else}
<Button
<IconButton
{...props}
{variant}
{size}
{rounded}
intent="neutral"
color="neutral"
iconOnly
aria-label="Clear"
aria-label={ariaLabel ?? 'Clear'}
>
{#snippet icon()}
{#if customGlyph}
{@render customGlyph()}
{:else}
<svg
width="12"
height="12"
@ -54,9 +55,8 @@
>
<path d="M3 3l8 8M11 3l-8 8" />
</svg>
{/snippet}
{#if children}{@render children()}{/if}
</Button>
{/if}
</IconButton>
{/if}
{/snippet}
</Combobox.ClearButton>

@ -0,0 +1,48 @@
# IconButton
The canonical **icon-only button**. A first-class public primitive (as in
MUI / Radix / Chakra), not an ad-hoc `<Button>` flag.
```svelte
<script>
import { IconButton } from '$uix/eidos/components/icon-button';
import { SvgX } from '$uix/eidos/components/svg';
</script>
<IconButton aria-label="Close" variant="ghost" rounded="full">
<SvgX />
</IconButton>
```
## What it is
A thin composition of `<Button>`. It **inherits Button's whole API** —
`variant`, `size`, `intent`, `color`, `rounded`, `shape`, `block`,
`loading`, the spinner — plus Button's **morfo + sema** (no new morfo). It
adds only the two things icon-only needs:
- the **square layout** (Button's internal icon-only mode), and
- a **required `aria-label`** — the visible glyph carries no text, so the
accessible name must be explicit.
## Doctrine
- **Use `<IconButton>` for every icon-only button.** Authoring icon-only
chrome by hand (a raw `<button>` with bespoke border/bg/hover/focus) is
the composition violation this primitive exists to remove (see
`COMPONENT_COHERENCE_AUDIT.md` dim. 1B). Compose it via soma's `child`
snippet from a component trigger, exactly like the `<Button>` consumers.
- **Glyph-agnostic.** Pass any `svg/` primitive (`SvgX`, `SvgChevron`,
`SvgPlus`/`SvgMinus`, …) or a lucide `Icon` as children. Prefer `svg/`
when the glyph animates or couples to state; lucide `icon/` for static
content (see `svg/README.md`).
- **`aria-label` is required.** Without it an icon-only control has no
accessible name. The prop is required at the type level.
## Relationship to `<Button>`
`<Button>` owns the click flow, `data-state`, the `contact-activate`
sema event and the visual recipe. `<IconButton>` is the same button,
square, with a glyph instead of a text label. The icon-only layout is an
internal Button mode — consumers reach it through `<IconButton>`, not a
public `iconOnly` flag.

@ -0,0 +1,23 @@
<script lang="ts">
/**
* Eidos `<IconButton>` — the canonical icon-only button.
*
* Composes `<Button>` so it inherits the full visual + behavioural API
* (variant / size / intent / color / rounded / shape / block / loading)
* plus Button's morfo + sema — no new morfo. It adds only what icon-only
* needs: the square layout (Button's icon-only mode) and a REQUIRED
* `aria-label`, since the visible glyph carries no text.
*
* <IconButton aria-label="Close" variant="ghost"><SvgX /></IconButton>
*
* Glyph-agnostic: the `children` glyph renders in Button's icon slot
* (visible); the body stays empty and the accessible name comes from
* `aria-label`. When `loading`, Button swaps the glyph for its spinner.
*/
import { Button } from '$uix/eidos/components/button';
import type { IconButtonProps } from './types';
let { 'aria-label': ariaLabel, children, ...rest }: IconButtonProps = $props();
</script>
<Button {...rest} aria-label={ariaLabel} iconOnly icon={children} />

@ -0,0 +1,18 @@
// IconButton — eidos icon-only button primitive.
//
// import { IconButton } from '$uix/eidos/components/icon-button';
//
// <IconButton aria-label="Close" variant="ghost" rounded="full">
// <SvgX />
// </IconButton>
//
// Composes <Button> (inherits variant/size/intent/color/rounded/shape/
// block/loading + Button's morfo + sema; no new morfo). The glyph is the
// children; `aria-label` is required (the glyph carries no text).
import IconButtonComponent from './icon-button.svelte';
export const IconButton = IconButtonComponent;
export default IconButton;
export type { IconButtonProps } from './types';

@ -0,0 +1,25 @@
import type { Snippet } from 'svelte';
import type { ButtonProps } from '$uix/eidos/components/button';
/**
* Props for the eidos `<IconButton>` — the canonical icon-only button.
*
* Composes `<Button>`, so it inherits the full visual + behavioural API
* (variant / size / intent / color / rounded / shape / block / loading)
* plus Button's morfo + sema — there is NO new morfo. It adds only what
* icon-only needs: the square layout and a REQUIRED `aria-label`, since the
* visible glyph carries no text label.
*
* The glyph is the `children` — any `svg/` primitive or lucide `Icon`:
*
* <IconButton aria-label="Close" variant="ghost"><SvgX /></IconButton>
*
* `icon` / `endIcon` are omitted (IconButton owns the single glyph slot);
* the icon-only layout is internal, so `iconOnly` is not part of the API.
*/
export type IconButtonProps = Omit<ButtonProps, 'icon' | 'endIcon' | 'children' | 'iconOnly'> & {
/** Accessible name — REQUIRED. The visible glyph carries no text label. */
'aria-label': string;
/** The glyph to render (an `svg/` primitive or a lucide `Icon`). */
children: Snippet;
};

@ -1,26 +1,26 @@
<script lang="ts">
/**
* Eidos `SearchField.ClearTrigger` — icon-only X that clears the input.
* Renders an eidos `<Button>` via soma's `child` snippet. Default
* `variant='ghost' iconOnly` — subtle clear affordance.
* Eidos `SearchField.ClearTrigger` — icon-only X that clears the input,
* rendered as an eidos `<IconButton>` via soma's `child` snippet. Default
* `variant='ghost'` — subtle clear affordance.
*
* Visibility (hidden when the field is empty) is still handled by
* `search-field.css` via the `[data-search-field][data-empty]`
* ancestor rule.
* Visibility (hidden when the field is empty) is handled by
* `search-field.css` via the `[data-search-field][data-empty]` ancestor.
*
* Composition: callers can pass their own `children` snippet (custom
* glyph) or `child` snippet (full bypass of Button).
* Composition: callers can pass a custom glyph via `children`, or a
* `child` snippet (full bypass of IconButton).
*/
import * as SearchField from '$soma/components/search-field';
import { Button } from '$uix/eidos/components/button';
import { IconButton } from '$uix/eidos/components/icon-button';
import type { SearchFieldClearTriggerProps } from './types';
let {
children,
children: customGlyph,
child: outerChild,
variant = 'ghost',
size,
rounded,
'aria-label': ariaLabel = 'Clear search',
...rest
}: SearchFieldClearTriggerProps = $props();
</script>
@ -30,16 +30,18 @@
{#if outerChild}
{@render outerChild({ props })}
{:else}
<Button
<IconButton
{...props}
{variant}
size={size ?? 'sm'}
{rounded}
intent="neutral"
color="neutral"
iconOnly
aria-label={ariaLabel ?? 'Clear search'}
>
{#snippet icon()}
{#if customGlyph}
{@render customGlyph()}
{:else}
<svg
width="12"
height="12"
@ -52,9 +54,8 @@
>
<path d="M3 3l8 8M11 3l-8 8" />
</svg>
{/snippet}
{#if children}{@render children()}{/if}
</Button>
{/if}
</IconButton>
{/if}
{/snippet}
</SearchField.ClearTrigger>

@ -17,4 +17,7 @@ export { default as SvgChevron } from './svg-chevron.svelte';
export { default as SvgCheck } from './svg-check.svelte';
export { default as SvgDash } from './svg-dash.svelte';
export { default as SvgDot } from './svg-dot.svelte';
export { default as SvgX } from './svg-x.svelte';
export { default as SvgPlus } from './svg-plus.svelte';
export { default as SvgMinus } from './svg-minus.svelte';
export type { SvgPrimitiveProps, SvgChevronDirection } from './types';

@ -0,0 +1,30 @@
<script lang="ts">
/**
* `<SvgMinus>` — minimal decrement glyph (−). Single stroke with
* `pathLength="100"` for draw animations. Pairs with `<SvgPlus>` for
* stepper / spin-field increment-decrement. Stroke uses `currentColor`.
*/
import { composeInlineStyle } from '$uix/eidos/lib/style';
import type { SvgPrimitiveProps } from './types';
let { size = '1em', strokeWidth = 2, style, ...rest }: SvgPrimitiveProps = $props();
const dim = $derived(typeof size === 'number' ? `${size}px` : size);
const mergedStyle = $derived(composeInlineStyle(`width: ${dim}; height: ${dim};`, style));
</script>
<svg
{...rest}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-linejoin="round"
focusable="false"
aria-hidden="true"
data-svg="minus"
style={mergedStyle}
>
<path d="M5 12h14" pathLength="100" />
</svg>

@ -0,0 +1,32 @@
<script lang="ts">
/**
* `<SvgPlus>` — minimal increment glyph (+). Two strokes with
* `pathLength="100"` so a consuming recipe can draw them via
* `stroke-dasharray` + `stroke-dashoffset`. Pairs with `<SvgMinus>` for
* stepper / spin-field increment-decrement. Stroke uses `currentColor`.
*/
import { composeInlineStyle } from '$uix/eidos/lib/style';
import type { SvgPrimitiveProps } from './types';
let { size = '1em', strokeWidth = 2, style, ...rest }: SvgPrimitiveProps = $props();
const dim = $derived(typeof size === 'number' ? `${size}px` : size);
const mergedStyle = $derived(composeInlineStyle(`width: ${dim}; height: ${dim};`, style));
</script>
<svg
{...rest}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-linejoin="round"
focusable="false"
aria-hidden="true"
data-svg="plus"
style={mergedStyle}
>
<path d="M5 12h14" pathLength="100" />
<path d="M12 5v14" pathLength="100" />
</svg>

@ -0,0 +1,32 @@
<script lang="ts">
/**
* `<SvgX>` — minimal close / dismiss glyph (✕). Two crossing strokes, each
* with `pathLength="100"` so a consuming recipe can draw or retract them via
* `stroke-dasharray` + `stroke-dashoffset` (e.g. animate the × on close).
* Stroke uses `currentColor` so colour follows the surrounding text.
*/
import { composeInlineStyle } from '$uix/eidos/lib/style';
import type { SvgPrimitiveProps } from './types';
let { size = '1em', strokeWidth = 2, style, ...rest }: SvgPrimitiveProps = $props();
const dim = $derived(typeof size === 'number' ? `${size}px` : size);
const mergedStyle = $derived(composeInlineStyle(`width: ${dim}; height: ${dim};`, style));
</script>
<svg
{...rest}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-linejoin="round"
focusable="false"
aria-hidden="true"
data-svg="x"
style={mergedStyle}
>
<path d="M18 6 6 18" pathLength="100" />
<path d="m6 6 12 12" pathLength="100" />
</svg>

@ -1,29 +1,25 @@
<script lang="ts">
/**
* Eidos `Table.RowDetailTrigger` — Button consumer pattern.
*
* Renders the soma RowDetailTrigger as an eidos `<Button>` via the
* `child` snippet so the trigger inherits the canonical Button chrome
* (size cascade + variant + intent + iconOnly). Default chrome is
* `iconOnly variant='ghost' rounded='md'` with a right-pointing
* chevron that rotates to down when the panel is open.
* Eidos `Table.RowDetailTrigger` — icon-only `<IconButton>` consumer.
* Default chrome `variant='ghost' rounded='md'` with a right-pointing
* chevron that rotates to down when the panel is open. The accessible
* name comes from `aria-label` (the glyph carries no text).
*/
import * as Table from '$soma/components/table';
import { Button } from '$uix/eidos/components/button';
import { IconButton } from '$uix/eidos/components/icon-button';
import { SvgChevron } from '$uix/eidos/components/svg';
import { getTableEidosContext } from './table-context';
import type { TableRowDetailTriggerProps } from './types';
let {
children,
child: outerChild,
variant = 'ghost',
size,
rounded = 'md',
intent,
color,
iconOnly = true,
icon: customIcon,
'aria-label': ariaLabel = 'Toggle row details',
...rest
}: TableRowDetailTriggerProps = $props();
@ -34,24 +30,12 @@
<Table.RowDetailTrigger {...rest}>
{#snippet child({ props })}
{@const isOpen = props['data-state'] === 'open'}
{#snippet defaultIcon()}
<SvgChevron direction={isOpen ? 'down' : 'right'} />
{/snippet}
{#if outerChild}
{@render outerChild({ props })}
{:else}
<Button
{...props}
{variant}
size={resolvedSize}
{rounded}
{intent}
{color}
{iconOnly}
icon={customIcon ?? defaultIcon}
>
{#if children}{@render children()}{/if}
</Button>
<IconButton {...props} {variant} size={resolvedSize} {rounded} {intent} {color} aria-label={ariaLabel}>
{#if customIcon}{@render customIcon()}{:else}<SvgChevron direction={isOpen ? 'down' : 'right'} />{/if}
</IconButton>
{/if}
{/snippet}
</Table.RowDetailTrigger>

@ -1,3 +1,4 @@
import type { Snippet } from 'svelte';
import type {
ColorRole,
ControlVariant,
@ -120,10 +121,10 @@ export type TableRowDetailTriggerProps = RowDetailTriggerProps & {
color?: EidosButtonProps['color'];
/** Evaluative intent — flows to Button. */
intent?: EidosButtonProps['intent'];
/** Render as icon-only. @default true */
iconOnly?: boolean;
/** Leading icon snippet (replaces default chevron). */
icon?: EidosButtonProps['icon'];
/** Accessible name for the icon-only trigger. @default 'Toggle row details' */
'aria-label'?: string;
/** Override the default chevron glyph. */
icon?: Snippet;
};
/**

@ -1,25 +1,25 @@
<script lang="ts">
/**
* Eidos `TreeGrid.ExpandTrigger` — Button consumer pattern.
* Default chrome: `iconOnly variant='ghost' rounded='md'` with a
* chevron that rotates by `data-state` (open/closed).
* Eidos `TreeGrid.ExpandTrigger` — icon-only `<IconButton>` consumer.
* Default chrome `variant='ghost' rounded='md'` with a chevron that
* rotates by `data-state` (open/closed). The accessible name comes from
* `aria-label` (the glyph carries no text).
*/
import * as TreeGrid from '$soma/components/tree-grid';
import { Button } from '$uix/eidos/components/button';
import { IconButton } from '$uix/eidos/components/icon-button';
import { SvgChevron } from '$uix/eidos/components/svg';
import { getTreeGridEidosContext } from './tree-grid-context';
import type { TreeGridExpandTriggerProps } from './types';
let {
children,
child: outerChild,
variant = 'ghost',
size,
rounded = 'md',
intent,
color,
iconOnly = true,
icon: customIcon,
'aria-label': ariaLabel = 'Toggle row',
...rest
}: TreeGridExpandTriggerProps = $props();
@ -27,27 +27,14 @@
const resolvedSize = $derived(size ?? ctx?.size ?? 'md');
</script>
{#snippet defaultIcon()}
<SvgChevron direction="right" />
{/snippet}
<TreeGrid.ExpandTrigger {...rest}>
{#snippet child({ props })}
{#if outerChild}
{@render outerChild({ props })}
{:else}
<Button
{...props}
{variant}
size={resolvedSize}
{rounded}
{intent}
{color}
{iconOnly}
icon={customIcon ?? defaultIcon}
>
{#if children}{@render children()}{/if}
</Button>
<IconButton {...props} {variant} size={resolvedSize} {rounded} {intent} {color} aria-label={ariaLabel}>
{#if customIcon}{@render customIcon()}{:else}<SvgChevron direction="right" />{/if}
</IconButton>
{/if}
{/snippet}
</TreeGrid.ExpandTrigger>

@ -1,3 +1,4 @@
import type { Snippet } from 'svelte';
import type {
ColorRole,
ControlVariant,
@ -103,10 +104,10 @@ export type TreeGridExpandTriggerProps = ExpandTriggerProps & {
color?: EidosButtonProps['color'];
/** Evaluative intent. */
intent?: EidosButtonProps['intent'];
/** Icon-only mode. @default true */
iconOnly?: boolean;
/** Leading icon snippet (replaces default chevron). */
icon?: EidosButtonProps['icon'];
/** Accessible name for the icon-only trigger. @default 'Toggle row' */
'aria-label'?: string;
/** Override the default chevron glyph. */
icon?: Snippet;
};
export type {

Loading…
Cancel
Save

Powered by TurnKey Linux.