Normalize Eidos component surface

active-uix
dev 5 months ago
parent 9bde5b212e
commit 13cc14f971

@ -49,6 +49,39 @@ Arranque rapido para mañana:
6. Mantener fuera de commits los logs sueltos (`.codex-vite-*`,
`debug.log`) salvo que el usuario pida conservarlos.
Actualizacion Eidos 2026-05-16:
- Primera pasada de componentes Option C ejecutada:
- Barrels multi-part normalizados a asignacion explicita per-property
(`const Drawer = DrawerRoot as DrawerNamespace; Drawer.Trigger = Trigger;`)
y sin `Object.assign`.
- Tipos root visuales renombrados a `AccordionProps`, `DrawerProps`,
`DialogProps`, `TabsProps`, etc. Eidos ya no define `XxxProviderProps`;
`ProviderProps` solo aparece como alias importado desde Soma cuando la
superficie headless lo llama asi.
- `src/uix/eidos/components/README.md` actualizado con la convencion real.
- `Toaster` deja de usar un alias interno `Provider` para no reintroducir el
modelo mental retirado.
- Aniadido `src/uix/eidos/lib/style.ts` con `composeInlineStyle(...)` y
aplicado a wrappers que mezclan estilo del consumidor con custom properties
de receta: overlay de Dialog/Drawer, Toast.Viewport, Checkbox.Group,
RadioGroup, Tabs.Indicator, Icon y primitives SVG.
- Validacion de esta pasada:
- `npm run check` -> 0 errores, 0 warnings.
- `npx vitest run src/uix/eidos` -> 4 archivos, 56 tests OK.
- `npm run test` -> 233 archivos, 2270 tests OK.
- SSR smoke de `web/routes/uix/components/{accordion,avatar,checkbox,
collapsible,dialog,drawer,popover,radio-group,switch,tabs,toast,toggle,
tooltip}` -> HTTP 200.
- Siguiente foco recomendado en Eidos:
1. Auditar recipes/CSS contra tokens generados por `ActiveEidos` y decidir
que CSS legacy queda como recipe por componente y que debe generarse.
2. Revisar componentes visuales que anaden partes eidos-only (`Header`,
`Footer`, `Status`, `Main`, SVG primitives) para documentar si necesitan
morfo propio o si son recipe primitives privadas.
3. Revisar tests de componentes Eidos: hoy hay cobertura del runtime Eidos,
pero no tests unitarios especificos para wrappers visuales.
Actualizacion 2026-05-16:
- Barrido de wrappers publicos Soma frente a providers cerrado:

@ -80,7 +80,7 @@ contexto headless, acepta los bindables del estado (`open`, `value`,
* to set up the component context.
*/
import { Provider as SomaDrawerProvider } from '$soma/components/drawer';
import type { DrawerProviderProps } from './types';
import type { DrawerProps } from './types';
let {
open = $bindable(false),
@ -88,7 +88,7 @@ contexto headless, acepta los bindables del estado (`open`, `value`,
isDragging = $bindable(false),
children,
...rest
}: DrawerProviderProps = $props();
}: DrawerProps = $props();
</script>
<SomaDrawerProvider {...rest} bind:open bind:activeSnapPoint bind:isDragging>
@ -169,7 +169,7 @@ export { Drawer };
export default Drawer;
export type {
DrawerProviderProps as DrawerProps,
DrawerProps,
DrawerTriggerProps as TriggerProps,
DrawerOverlayProps as OverlayProps,
DrawerContentProps as ContentProps
@ -187,7 +187,7 @@ import type {
// …
} from '$soma/components/drawer';
export type DrawerProviderProps = SomaDrawerProviderProps;
export type DrawerProps = SomaDrawerProviderProps;
export type DrawerTriggerProps = SomaDrawerTriggerProps;
// Eidos añade props visuales que el recipe consume vía data-attrs

@ -18,7 +18,7 @@
*/
import * as SomaAccordion from '$soma/components/accordion';
import { ActiveEidos } from '$uix/eidos';
import type { AccordionProviderProps } from './types';
import type { AccordionProps } from './types';
import { setAccordionEidosContext } from './accordion-context';
let {
@ -27,7 +27,7 @@
variant = 'outline',
children,
...rest
}: AccordionProviderProps = $props();
}: AccordionProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(typeof size === 'string' ? size : eidos.resolve(size, 'md'));

@ -12,25 +12,33 @@
// </Accordion>
//
// `<Accordion>` IS the root (lives in accordion.svelte). Children
// attached via Object.assign — bits-ui / shadcn-svelte pattern. No
// Provider exported publicly; no flat-with-snippets invention.
// are attached explicitly. No Provider exported publicly; no
// flat-with-snippets invention.
import AccordionRoot from './accordion.svelte';
import Item from './accordion-item.svelte';
import Header from './accordion-header.svelte';
import Trigger from './accordion-trigger.svelte';
import Content from './accordion-content.svelte';
export const Accordion = Object.assign(AccordionRoot, {
Item,
Header,
Trigger,
Content
});
type AccordionNamespace = typeof AccordionRoot & {
Item: typeof Item;
Header: typeof Header;
Trigger: typeof Trigger;
Content: typeof Content;
};
const Accordion = AccordionRoot as AccordionNamespace;
Accordion.Item = Item;
Accordion.Header = Header;
Accordion.Trigger = Trigger;
Accordion.Content = Content;
export { Accordion };
export default Accordion;
export type {
AccordionProviderProps as AccordionProps,
AccordionProps,
AccordionItemProps as ItemProps,
AccordionHeaderProps as HeaderProps,
AccordionTriggerProps as TriggerProps,

@ -15,14 +15,14 @@ export type AccordionHeaderProps = SomaAccordionHeaderProps;
export type AccordionContentProps = SomaAccordionContentProps;
/**
* Provider props in eidos accept `ResponsiveProp<AccordionSize>` for
* Root props in eidos accept `ResponsiveProp<AccordionSize>` for
* the `size` axis. The wrapper resolves it via `Eidos.resolve` and
* stamps `data-size`; headless never receives size/variant because those
* axes are visual-only. Variant stays single-valued — picking outline
* vs surface vs ghost on a per-breakpoint basis is not a use case worth
* exposing.
*/
export type AccordionProviderProps = SomaAccordionProviderProps & {
export type AccordionProps = SomaAccordionProviderProps & {
size?: ResponsiveProp<AccordionSize>;
variant?: AccordionVariant;
};

@ -9,7 +9,7 @@
* <Avatar.Fallback>JD</Avatar.Fallback>
* </Avatar>
*/
import type { AvatarProviderProps } from './types';
import type { AvatarProps } from './types';
let {
size = 'md',
@ -18,7 +18,7 @@
radius = 'full',
children,
...restProps
}: AvatarProviderProps = $props();
}: AvatarProps = $props();
</script>
<span

@ -14,15 +14,21 @@ import AvatarRoot from './avatar.svelte';
import Image from './avatar-image.svelte';
import Fallback from './avatar-fallback.svelte';
export const Avatar = Object.assign(AvatarRoot, {
Image,
Fallback
});
type AvatarNamespace = typeof AvatarRoot & {
Image: typeof Image;
Fallback: typeof Fallback;
};
const Avatar = AvatarRoot as AvatarNamespace;
Avatar.Image = Image;
Avatar.Fallback = Fallback;
export { Avatar };
export default Avatar;
export type {
AvatarProviderProps as AvatarProps,
AvatarProps,
AvatarImageProps as ImageProps,
AvatarFallbackProps as FallbackProps,
AvatarSize,

@ -15,7 +15,7 @@ export type AvatarColor =
| 'loss';
export type AvatarRadius = 'full' | 'md' | 'sm' | 'none';
export type AvatarProviderProps = Omit<HTMLAttributes<HTMLSpanElement>, 'children'> & {
export type AvatarProps = Omit<HTMLAttributes<HTMLSpanElement>, 'children'> & {
/** Visual size token. @default 'md' */
size?: AvatarSize;
/** Visual variant. @default 'soft' */

@ -7,16 +7,22 @@
* - horizontal → flex row (or grid with auto cols if columns set)
*/
import * as Checkbox from '$soma/components/checkbox';
import { composeInlineStyle } from '$uix/eidos/lib/style';
import type { CheckboxGroupProps } from './types';
let { columns, value = $bindable([]), children, ...rest }: CheckboxGroupProps = $props();
let { columns, value = $bindable([]), style, children, ...rest }: CheckboxGroupProps = $props();
const columnsStyle = $derived(
columns ? `--_checkbox-group-columns: ${columns};` : undefined
);
const mergedStyle = $derived(composeInlineStyle(style, columnsStyle));
</script>
<Checkbox.Group
{...rest}
bind:value
data-columns={columns ?? undefined}
style={columns ? `--_checkbox-group-columns: ${columns};` : undefined}
style={mergedStyle}
>
{@render children?.()}
</Checkbox.Group>

@ -12,7 +12,7 @@
*/
import { ActiveEidos } from '$uix/eidos';
import * as SomaCheckbox from '$soma/components/checkbox';
import type { CheckboxProviderProps } from './types';
import type { CheckboxProps } from './types';
let {
size = 'md',
@ -22,7 +22,7 @@
indeterminate = $bindable(false),
children: bodyContent,
...rest
}: CheckboxProviderProps = $props();
}: CheckboxProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));

@ -18,17 +18,25 @@ import HiddenInput from './checkbox-hidden-input.svelte';
import Group from './checkbox-group.svelte';
import GroupLabel from './checkbox-group-label.svelte';
export const Checkbox = Object.assign(CheckboxRoot, {
Indicator,
HiddenInput,
Group,
GroupLabel
});
type CheckboxNamespace = typeof CheckboxRoot & {
Indicator: typeof Indicator;
HiddenInput: typeof HiddenInput;
Group: typeof Group;
GroupLabel: typeof GroupLabel;
};
const Checkbox = CheckboxRoot as CheckboxNamespace;
Checkbox.Indicator = Indicator;
Checkbox.HiddenInput = HiddenInput;
Checkbox.Group = Group;
Checkbox.GroupLabel = GroupLabel;
export { Checkbox };
export default Checkbox;
export type {
CheckboxProviderProps as CheckboxProps,
CheckboxProps,
CheckboxIndicatorProps as IndicatorProps,
CheckboxHiddenInputProps as HiddenInputProps,
CheckboxGroupProps as GroupProps,

@ -44,7 +44,7 @@ export type CheckboxIconSnippetArgs = {
indeterminate: boolean;
};
export type CheckboxProviderProps = SomaCheckboxProviderProps & {
export type CheckboxProps = SomaCheckboxProviderProps & {
/** Visual size preset. Resolved via `Eidos.resolve`. @default 'md' */
size?: ResponsiveProp<CheckboxSize>;
/** Visual variant. @default 'solid' */

@ -10,10 +10,16 @@ import CollapsibleRoot from './collapsible.svelte';
import Trigger from './collapsible-trigger.svelte';
import Content from './collapsible-content.svelte';
export const Collapsible = Object.assign(CollapsibleRoot, {
Trigger,
Content
});
type CollapsibleNamespace = typeof CollapsibleRoot & {
Trigger: typeof Trigger;
Content: typeof Content;
};
const Collapsible = CollapsibleRoot as CollapsibleNamespace;
Collapsible.Trigger = Trigger;
Collapsible.Content = Content;
export { Collapsible };
export default Collapsible;

@ -6,6 +6,7 @@
* inline `--dialog-overlay-opacity` custom property so the recipe's
* `color-mix` picks it up without a class swap.
*/
import { composeInlineStyle } from '$uix/eidos/lib/style';
import * as Dialog from '$soma/components/dialog';
import type { DialogOverlayProps } from './types';
@ -23,12 +24,10 @@
const resolvedOpacity = $derived(serializeOpacity(overlayOpacity));
const composedStyle = $derived(
[style, resolvedOpacity ? `--dialog-overlay-opacity:${resolvedOpacity}` : '']
.filter(Boolean)
.join(';')
composeInlineStyle(style, resolvedOpacity ? `--dialog-overlay-opacity: ${resolvedOpacity};` : undefined)
);
</script>
<Dialog.Overlay {...rest} style={composedStyle || undefined}>
<Dialog.Overlay {...rest} style={composedStyle}>
{@render children?.()}
</Dialog.Overlay>

@ -20,9 +20,9 @@
* </Dialog>
*/
import * as SomaDialog from '$soma/components/dialog';
import type { DialogProviderProps } from './types';
import type { DialogProps } from './types';
let { open = $bindable(false), children, ...rest }: DialogProviderProps = $props();
let { open = $bindable(false), children, ...rest }: DialogProps = $props();
</script>
<SomaDialog.Provider {...rest} bind:open>

@ -27,22 +27,35 @@ import Header from './dialog-header.svelte';
import Footer from './dialog-footer.svelte';
import { Portal } from '$soma/components/internal';
export const Dialog = Object.assign(DialogRoot, {
Trigger,
Portal,
Overlay,
Content,
Title,
Description,
Close,
Header,
Footer
});
type DialogNamespace = typeof DialogRoot & {
Trigger: typeof Trigger;
Portal: typeof Portal;
Overlay: typeof Overlay;
Content: typeof Content;
Title: typeof Title;
Description: typeof Description;
Close: typeof Close;
Header: typeof Header;
Footer: typeof Footer;
};
const Dialog = DialogRoot as DialogNamespace;
Dialog.Trigger = Trigger;
Dialog.Portal = Portal;
Dialog.Overlay = Overlay;
Dialog.Content = Content;
Dialog.Title = Title;
Dialog.Description = Description;
Dialog.Close = Close;
Dialog.Header = Header;
Dialog.Footer = Footer;
export { Dialog };
export default Dialog;
export type {
DialogProviderProps as DialogProps,
DialogProps,
DialogTriggerProps as TriggerProps,
DialogOverlayProps as OverlayProps,
DialogContentProps as ContentProps,

@ -27,7 +27,7 @@ export type DialogSize = Extract<Size, 'sm' | 'md' | 'lg' | 'xl' | 'full'>;
*/
export type DialogPosition = Position;
export type DialogProviderProps = SomaDialogProviderProps;
export type DialogProps = SomaDialogProviderProps;
export type DialogTriggerProps = SomaDialogTriggerProps;
export type DialogContentProps = SomaDialogContentProps & {

@ -11,6 +11,7 @@
* wrapper still mounts the headless part for consistency, but it has no
* visual effect.
*/
import { composeInlineStyle } from '$uix/eidos/lib/style';
import * as Drawer from '$soma/components/drawer';
import type { DrawerOverlayProps } from './types';
@ -28,12 +29,10 @@
const resolvedOpacity = $derived(serializeOpacity(overlayOpacity));
const composedStyle = $derived(
[style, resolvedOpacity ? `--drawer-overlay-opacity:${resolvedOpacity}` : '']
.filter(Boolean)
.join(';')
composeInlineStyle(style, resolvedOpacity ? `--drawer-overlay-opacity: ${resolvedOpacity};` : undefined)
);
</script>
<Drawer.Overlay {...rest} style={composedStyle || undefined}>
<Drawer.Overlay {...rest} style={composedStyle}>
{@render children?.()}
</Drawer.Overlay>

@ -13,7 +13,7 @@
* </Drawer>
*/
import { Provider as SomaDrawerProvider } from '$soma/components/drawer';
import type { DrawerProviderProps } from './types';
import type { DrawerProps } from './types';
let {
open = $bindable(false),
@ -21,7 +21,7 @@
isDragging = $bindable(false),
children,
...rest
}: DrawerProviderProps = $props();
}: DrawerProps = $props();
</script>
<SomaDrawerProvider {...rest} bind:open bind:activeSnapPoint bind:isDragging>

@ -18,9 +18,9 @@
// </Drawer>
//
// `<Drawer>` IS the root (lives in drawer.svelte, wraps Soma's
// Drawer.Provider). All child parts are attached as named members via
// Object.assign — bits-ui / shadcn-svelte pattern. No Provider exported
// publicly; no flat-with-snippet-slots invention. Children naming
// Drawer.Provider). All child parts are attached as named members
// explicitly. No Provider exported publicly; no flat-with-snippet-slots
// invention. Children naming
// follows air / headless: Trigger, Content, Overlay, Title, Description,
// Close, Portal (+ Header, Footer as eidos-only layout shells, and
// Handle inherited from Soma's drag affordance).
@ -36,9 +36,9 @@ import Header from './drawer-header.svelte';
import Footer from './drawer-footer.svelte';
import { Portal } from '$soma/components/internal';
// Explicit per-property assignment instead of `Object.assign(DrawerRoot, ...)`
// Explicit per-property assignment instead of bulk assignment
// to avoid a Svelte 5 hydration quirk where mutating the component
// constructor with bulk Object.assign caused `<Drawer.Trigger>` children
// constructor in bulk caused `<Drawer.Trigger>` children
// to re-mount on hydration (button appeared, then disappeared).
type DrawerNamespace = typeof DrawerRoot & {
Trigger: typeof Trigger;
@ -69,7 +69,7 @@ export { Drawer };
export default Drawer;
export type {
DrawerProviderProps as DrawerProps,
DrawerProps,
DrawerTriggerProps as TriggerProps,
DrawerOverlayProps as OverlayProps,
DrawerContentProps as ContentProps,

@ -24,7 +24,7 @@ export type DrawerSize = Extract<Size, 'sm' | 'md' | 'lg' | 'full'>;
export type { DrawerVariant, DrawerDirection, DrawerSide };
export type DrawerProviderProps = SomaDrawerProviderProps;
export type DrawerProps = SomaDrawerProviderProps;
export type DrawerTriggerProps = SomaDrawerTriggerProps;
export type DrawerHandleProps = SomaDrawerHandleProps;
export type DrawerTitleProps = SomaDrawerTitleProps;

@ -1,4 +1,5 @@
<script lang="ts">
import { composeInlineStyle } from '$uix/eidos/lib/style';
import type { IconProps } from './types';
import { resolveIconSize, resolveIconStrokeWidth } from './create-icon';
@ -16,10 +17,9 @@
const resolvedSize = $derived(resolveIconSize(size));
const resolvedStrokeWidth = $derived(resolveIconStrokeWidth(strokeWidth, size));
const mergedStyle = $derived.by(() => {
const baseStyle = `width:${resolvedSize};height:${resolvedSize};`;
return style ? `${baseStyle}${style}` : baseStyle;
});
const mergedStyle = $derived(
composeInlineStyle(`width: ${resolvedSize}; height: ${resolvedSize};`, style)
);
</script>
<svg

@ -21,20 +21,31 @@ import Anchor from './popover-anchor.svelte';
import Overlay from './popover-overlay.svelte';
import { Portal } from '$soma/components/internal';
export const Popover = Object.assign(PopoverRoot, {
Trigger,
Portal,
Anchor,
Overlay,
Content,
Arrow,
Close
});
type PopoverNamespace = typeof PopoverRoot & {
Trigger: typeof Trigger;
Portal: typeof Portal;
Anchor: typeof Anchor;
Overlay: typeof Overlay;
Content: typeof Content;
Arrow: typeof Arrow;
Close: typeof Close;
};
const Popover = PopoverRoot as PopoverNamespace;
Popover.Trigger = Trigger;
Popover.Portal = Portal;
Popover.Anchor = Anchor;
Popover.Overlay = Overlay;
Popover.Content = Content;
Popover.Arrow = Arrow;
Popover.Close = Close;
export { Popover };
export default Popover;
export type {
PopoverProviderProps as PopoverProps,
PopoverProps,
PopoverTriggerProps as TriggerProps,
PopoverContentProps as ContentProps,
PopoverArrowProps as ArrowProps,

@ -1,8 +1,8 @@
<script lang="ts">
/**
* Eidos `<Popover.Overlay>` — passthrough wrapper for the headless
* Overlay part. Only meaningful for MODAL popovers (`<Popover.Provider
* modal>`); the recipe handles backdrop styling.
* Overlay part. Only meaningful for modal `<Popover modal>` roots; the
* recipe handles backdrop styling.
*/
import * as Popover from '$soma/components/popover';
import type { PopoverOverlayProps } from './types';

@ -16,9 +16,9 @@
* </Popover>
*/
import * as SomaPopover from '$soma/components/popover';
import type { PopoverProviderProps } from './types';
import type { PopoverProps } from './types';
let { open = $bindable(false), children, ...rest }: PopoverProviderProps = $props();
let { open = $bindable(false), children, ...rest }: PopoverProps = $props();
</script>
<SomaPopover.Provider {...rest} bind:open>

@ -16,7 +16,7 @@ import type {
*/
export type PopoverSize = Extract<Size, 'sm' | 'md' | 'lg' | 'xl'>;
export type PopoverProviderProps = SomaPopoverProviderProps;
export type PopoverProps = SomaPopoverProviderProps;
export type PopoverTriggerProps = SomaPopoverTriggerProps;
export type PopoverArrowProps = SomaPopoverArrowProps;
export type PopoverCloseProps = SomaPopoverCloseProps;

@ -19,17 +19,25 @@ import Indicator from './radio-group-indicator.svelte';
import HiddenInput from './radio-group-hidden-input.svelte';
import Label from './radio-group-label.svelte';
export const RadioGroup = Object.assign(RadioGroupRoot, {
Item,
Indicator,
HiddenInput,
Label
});
type RadioGroupNamespace = typeof RadioGroupRoot & {
Item: typeof Item;
Indicator: typeof Indicator;
HiddenInput: typeof HiddenInput;
Label: typeof Label;
};
const RadioGroup = RadioGroupRoot as RadioGroupNamespace;
RadioGroup.Item = Item;
RadioGroup.Indicator = Indicator;
RadioGroup.HiddenInput = HiddenInput;
RadioGroup.Label = Label;
export { RadioGroup };
export default RadioGroup;
export type {
RadioGroupProviderProps as RadioGroupProps,
RadioGroupProps,
RadioGroupItemProps as ItemProps,
RadioGroupIndicatorProps as IndicatorProps,
RadioGroupHiddenInputProps as HiddenInputProps,

@ -14,8 +14,9 @@
* </RadioGroup>
*/
import { ActiveEidos } from '$uix/eidos';
import { composeInlineStyle } from '$uix/eidos/lib/style';
import * as SomaRadioGroup from '$soma/components/radio-group';
import type { RadioGroupProviderProps } from './types';
import type { RadioGroupProps } from './types';
let {
size = 'md',
@ -23,12 +24,17 @@
color = 'primary',
columns,
value = $bindable(''),
style,
children,
...rest
}: RadioGroupProviderProps = $props();
}: RadioGroupProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
const columnsStyle = $derived(
columns ? `--_radio-group-columns: ${columns};` : undefined
);
const mergedStyle = $derived(composeInlineStyle(style, columnsStyle));
</script>
<SomaRadioGroup.Provider
@ -38,7 +44,7 @@
data-variant={variant}
data-color={color}
data-columns={columns ?? undefined}
style={columns ? `--_radio-group-columns: ${columns};` : undefined}
style={mergedStyle}
>
{@render children?.()}
</SomaRadioGroup.Provider>

@ -41,7 +41,7 @@ export type RadioGroupIconSnippetArgs = {
checked: boolean;
};
export type RadioGroupProviderProps = SomaRadioGroupProviderProps & {
export type RadioGroupProps = SomaRadioGroupProviderProps & {
/** Visual size preset. Resolved via `Eidos.resolve`. @default 'md' */
size?: ResponsiveProp<RadioGroupSize>;
/** Visual variant. @default 'solid' */

@ -10,6 +10,7 @@
* `pathLength="100"` so the dasharray maths stays consumer-readable
* regardless of the path's actual length in user units.
*/
import { composeInlineStyle } from '$uix/eidos/lib/style';
import type { SvgPrimitiveProps } from './types';
let {
@ -20,15 +21,7 @@
}: SvgPrimitiveProps = $props();
const dim = $derived(typeof size === 'number' ? `${size}px` : size);
const mergedStyle = $derived(
[
`width: ${dim}`,
`height: ${dim}`,
typeof style === 'string' ? style : ''
]
.filter(Boolean)
.join('; ')
);
const mergedStyle = $derived(composeInlineStyle(`width: ${dim}; height: ${dim};`, style));
</script>
<svg

@ -12,6 +12,7 @@
* prop, because the prop sets the BASELINE direction and the recipe
* sets the dynamic one.
*/
import { composeInlineStyle } from '$uix/eidos/lib/style';
import type { SvgPrimitiveProps, SvgChevronDirection } from './types';
type Props = SvgPrimitiveProps & {
@ -37,14 +38,11 @@
const dim = $derived(typeof size === 'number' ? `${size}px` : size);
const rotation = $derived(ROTATION[direction]);
const mergedStyle = $derived(
[
`width: ${dim}`,
`height: ${dim}`,
rotation !== 0 ? `transform: rotate(${rotation}deg)` : '',
typeof style === 'string' ? style : ''
]
.filter(Boolean)
.join('; ')
composeInlineStyle(
`width: ${dim}; height: ${dim};`,
rotation !== 0 ? `transform: rotate(${rotation}deg);` : undefined,
style
)
);
</script>

@ -8,6 +8,7 @@
* recipes can animate via `stroke-dashoffset` without measuring the
* actual path length.
*/
import { composeInlineStyle } from '$uix/eidos/lib/style';
import type { SvgPrimitiveProps } from './types';
let {
@ -18,15 +19,7 @@
}: SvgPrimitiveProps = $props();
const dim = $derived(typeof size === 'number' ? `${size}px` : size);
const mergedStyle = $derived(
[
`width: ${dim}`,
`height: ${dim}`,
typeof style === 'string' ? style : ''
]
.filter(Boolean)
.join('; ')
);
const mergedStyle = $derived(composeInlineStyle(`width: ${dim}; height: ${dim};`, style));
</script>
<svg

@ -8,6 +8,7 @@
* viewBox so the recipe can scale it via `transform: scale(...)` from
* a small radius to full to animate the "select" moment.
*/
import { composeInlineStyle } from '$uix/eidos/lib/style';
import type { SvgPrimitiveProps } from './types';
let {
@ -18,15 +19,7 @@
}: SvgPrimitiveProps = $props();
const dim = $derived(typeof size === 'number' ? `${size}px` : size);
const mergedStyle = $derived(
[
`width: ${dim}`,
`height: ${dim}`,
typeof style === 'string' ? style : ''
]
.filter(Boolean)
.join('; ')
);
const mergedStyle = $derived(composeInlineStyle(`width: ${dim}; height: ${dim};`, style));
</script>
<svg

@ -17,17 +17,25 @@ import Trigger from './tabs-trigger.svelte';
import Content from './tabs-content.svelte';
import Indicator from './tabs-indicator.svelte';
export const Tabs = Object.assign(TabsRoot, {
List,
Trigger,
Content,
Indicator
});
type TabsNamespace = typeof TabsRoot & {
List: typeof List;
Trigger: typeof Trigger;
Content: typeof Content;
Indicator: typeof Indicator;
};
const Tabs = TabsRoot as TabsNamespace;
Tabs.List = List;
Tabs.Trigger = Trigger;
Tabs.Content = Content;
Tabs.Indicator = Indicator;
export { Tabs };
export default Tabs;
export type {
TabsProviderProps as TabsProps,
TabsProps,
TabsListProps as ListProps,
TabsTriggerProps as TriggerProps,
TabsContentProps as ContentProps,

@ -16,10 +16,11 @@
* snaps to the active trigger).
*/
import { onMount } from 'svelte';
import { composeInlineStyle } from '$uix/eidos/lib/style';
import * as Tabs from '$soma/components/tabs';
import type { TabsIndicatorProps } from './types';
let { ref = $bindable<HTMLElement | null>(null), ...rest }: TabsIndicatorProps = $props();
let { ref = $bindable<HTMLElement | null>(null), style, ...rest }: TabsIndicatorProps = $props();
let geom = $state({ x: 0, y: 0, w: 0, h: 0, ready: false });
let rafId: number | null = null;
@ -46,6 +47,13 @@
});
}
const geometryStyle = $derived(
geom.ready
? `transform: translate3d(${geom.x}px, ${geom.y}px, 0); width: ${geom.w}px; height: ${geom.h}px;`
: 'opacity: 0;'
);
const mergedStyle = $derived(composeInlineStyle(style, geometryStyle));
onMount(() => {
const el = ref;
if (!el) return;
@ -78,8 +86,6 @@
<Tabs.Indicator
{...rest}
bind:ref
style={geom.ready
? `transform: translate3d(${geom.x}px, ${geom.y}px, 0); width: ${geom.w}px; height: ${geom.h}px;`
: 'opacity: 0;'}
style={mergedStyle}
data-ready={geom.ready ? '' : undefined}
/>

@ -22,7 +22,7 @@
*/
import { ActiveEidos } from '$uix/eidos';
import * as SomaTabs from '$soma/components/tabs';
import type { TabsProviderProps } from './types';
import type { TabsProps } from './types';
import { setTabsEidosContext } from './tabs-context';
let {
@ -36,7 +36,7 @@
ref = $bindable<HTMLElement | null>(null),
children,
...rest
}: TabsProviderProps = $props();
}: TabsProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));

@ -46,7 +46,7 @@ export type TabsMotionPreset = 'none' | 'fade' | 'slide';
*/
export type TabsCollapseAt = 'never' | Breakpoint | number;
export type TabsProviderProps = SomaTabsProviderProps & {
export type TabsProps = SomaTabsProviderProps & {
/**
* Visual size preset. Resolved via `Eidos.resolve`. Pass either a single
* value or a breakpoint map (`{ base: 'sm', md: 'md' }`).

@ -3,8 +3,8 @@
// Two distinct roots, NOT one wrapping the other:
//
// - `<Toast>` — manual compound. Wraps Soma's Provider; consumer
// iterates `toaster.toasts` themselves. Children attached via
// Object.assign: Viewport, Item, Status, Main, Title, Description,
// iterates `toaster.toasts` themselves. Children attached explicitly:
// Viewport, Item, Status, Main, Title, Description,
// Action, Close.
//
// - `<Toaster />` — imperative auto-mount. Renders the entire
@ -45,16 +45,28 @@ import Description from './toast-description.svelte';
import Action from './toast-action.svelte';
import Close from './toast-close.svelte';
export const Toast = Object.assign(ToastRoot, {
Viewport,
Item,
Status,
Main,
Title,
Description,
Action,
Close
});
type ToastNamespace = typeof ToastRoot & {
Viewport: typeof Viewport;
Item: typeof Item;
Status: typeof Status;
Main: typeof Main;
Title: typeof Title;
Description: typeof Description;
Action: typeof Action;
Close: typeof Close;
};
const Toast = ToastRoot as ToastNamespace;
Toast.Viewport = Viewport;
Toast.Item = Item;
Toast.Status = Status;
Toast.Main = Main;
Toast.Title = Title;
Toast.Description = Description;
Toast.Action = Action;
Toast.Close = Close;
export { Toast };
export default Toast;
@ -73,7 +85,7 @@ export {
export type {
ToasterProps,
ToastProviderProps as ToastProps,
ToastProps,
ToastViewportProps as ViewportProps,
ToastItemProps as ItemProps,
ToastStatusProps as StatusProps,

@ -5,38 +5,19 @@
* `--toast-toaster-gap` token inline. Forwards everything else to the
* headless Viewport.
*/
import { composeInlineStyle } from '$uix/eidos/lib/style';
import * as Toast from '$soma/components/toast';
import type { ToastViewportProps } from './types';
let { position = 'bottom-right', gap, style, children, ...rest }: ToastViewportProps = $props();
function styleObjectToString(obj: Record<string, string | number | undefined>): string {
return Object.entries(obj)
.filter(([, v]) => v !== undefined && v !== null)
.map(([k, v]) => `${k}: ${v};`)
.join(' ');
}
const gapStyle = $derived.by(() => {
if (gap === undefined) return '';
const value = typeof gap === 'number' ? `${gap}px` : gap;
return `--toast-toaster-gap: ${value};`;
});
// Preserve consumer-supplied style whether they pass a string or an
// object. Previously the object branch was silently dropped when `gap`
// was set, because we only read `typeof style === 'string'`.
const mergedStyle = $derived.by(() => {
const userStyle =
typeof style === 'string'
? style
: style && typeof style === 'object'
? styleObjectToString(style as Record<string, string | number | undefined>)
: '';
if (!gapStyle && !userStyle) return undefined;
if (!gapStyle) return userStyle;
return userStyle ? `${gapStyle} ${userStyle}` : gapStyle;
});
const mergedStyle = $derived(composeInlineStyle(style, gapStyle));
</script>
<Toast.Viewport data-position={position} style={mergedStyle} {...rest}>

@ -20,9 +20,9 @@
* not a child).
*/
import * as SomaToast from '$soma/components/toast';
import type { ToastProviderProps } from './types';
import type { ToastProps } from './types';
let { toaster, label, children, ...rest }: ToastProviderProps = $props();
let { toaster, label, children, ...rest }: ToastProps = $props();
</script>
<SomaToast.Provider {toaster} {label} {...rest}>

@ -1,6 +1,6 @@
<script lang="ts">
/**
* Eidos flat `<Toaster>` — the ergonomic 90 % case.
* Eidos auto-mount `<Toaster>` — the ergonomic 90 % case.
*
* <Toaster {toaster} position="top-right" />
*
@ -8,14 +8,14 @@
* (status disc fed by `<IntentIcon>`, title, description, action,
* close). For exotic shapes — custom card layout, multiple toasters,
* compound trees in different DOM subtrees — the consumer drops down
* to `<Toast.Provider> / <Toast.Viewport> / <Toast.Item>` directly.
* to `<Toast> / <Toast.Viewport> / <Toast.Item>` directly.
*
* The intent → glyph mapping lives in
* `$uix/eidos/components/icon/intent.ts` so any other surface that
* needs intent icons (Dialog, Field, Banner) reads from the same
* doctrinal source.
*/
import Provider from './toast.svelte';
import ToastRoot from './toast.svelte';
import Viewport from './toast-viewport.svelte';
import Item from './toast-item.svelte';
import Status from './toast-status.svelte';
@ -36,7 +36,7 @@
}: ToasterProps = $props();
</script>
<Provider {toaster} {label}>
<ToastRoot {toaster} {label}>
<Viewport {position} {gap}>
{#each toaster.toasts as item (item.id)}
<Item toast={item}>
@ -70,4 +70,4 @@
</Item>
{/each}
</Viewport>
</Provider>
</ToastRoot>

@ -26,7 +26,7 @@ export type ToastPosition =
| 'bottom-center'
| 'bottom-right';
export type ToastProviderProps = SomaToastProviderProps;
export type ToastProps = SomaToastProviderProps;
export type ToastTitleProps = SomaToastTitleProps;
export type ToastDescriptionProps = SomaToastDescriptionProps;
export type ToastActionProps = SomaToastActionProps;
@ -56,7 +56,7 @@ export type ToastViewportProps = SomaToastViewportProps & {
* (typically in the root layout), receives the `toaster` instance, and
* renders Provider + Viewport + Item-loop with default content layout
* (status icon, title, description, action, close). Use the compound
* `<Toast.Provider>` / `<Toast.Viewport>` / `<Toast.Item>` shape when
* `<Toast>` / `<Toast.Viewport>` / `<Toast.Item>` shape when
* you need a non-default item template.
*/
export type ToasterProps = {

@ -20,18 +20,27 @@ import Content from './tooltip-content.svelte';
import Arrow from './tooltip-arrow.svelte';
import { Portal } from '$soma/components/internal';
export const Tooltip = Object.assign(TooltipRoot, {
Group,
Trigger,
Portal,
Content,
Arrow
});
type TooltipNamespace = typeof TooltipRoot & {
Group: typeof Group;
Trigger: typeof Trigger;
Portal: typeof Portal;
Content: typeof Content;
Arrow: typeof Arrow;
};
const Tooltip = TooltipRoot as TooltipNamespace;
Tooltip.Group = Group;
Tooltip.Trigger = Trigger;
Tooltip.Portal = Portal;
Tooltip.Content = Content;
Tooltip.Arrow = Arrow;
export { Tooltip };
export default Tooltip;
export type {
TooltipProviderProps as TooltipProps,
TooltipProps,
TooltipGroupProps as GroupProps,
TooltipTriggerProps as TriggerProps,
TooltipContentProps as ContentProps,

@ -19,9 +19,9 @@
* tooltip to a sibling is instantaneous within the grace window.
*/
import * as SomaTooltip from '$soma/components/tooltip';
import type { TooltipProviderProps } from './types';
import type { TooltipProps } from './types';
let { open = $bindable(false), children, ...rest }: TooltipProviderProps = $props();
let { open = $bindable(false), children, ...rest }: TooltipProps = $props();
</script>
<SomaTooltip.Provider {...rest} bind:open>

@ -24,7 +24,7 @@ export type TooltipSize = Extract<Size, 'sm' | 'md' | 'lg'>;
export type TooltipVariant = 'solid' | 'outline' | 'ghost';
export type TooltipGroupProps = SomaTooltipGroupProps;
export type TooltipProviderProps = SomaTooltipProviderProps;
export type TooltipProps = SomaTooltipProviderProps;
export type TooltipTriggerProps = SomaTooltipTriggerProps;
export type TooltipArrowProps = SomaTooltipArrowProps;

@ -0,0 +1,17 @@
type StyleRecord = Record<string, string | number | null | undefined>;
function serializeStyle(style: unknown): string {
if (!style) return '';
if (typeof style === 'string') return style.trim();
if (typeof style !== 'object') return '';
return Object.entries(style as StyleRecord)
.filter(([, value]) => value !== undefined && value !== null)
.map(([name, value]) => `${name}: ${value};`)
.join(' ');
}
export function composeInlineStyle(...styles: unknown[]): string | undefined {
const parts = styles.map(serializeStyle).filter(Boolean);
return parts.length ? parts.join(' ') : undefined;
}
Loading…
Cancel
Save

Powered by TurnKey Linux.