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
83 changes: 83 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ Leverages Svelte's fine-grained reactivity system for optimal performance and sm

Components are headless by default, giving you complete control over styling while providing sensible defaults.

### 🎨 **Composable**

Build complex UIs by combining simple, reusable components. Each component is designed to work seamlessly with others through the Bond pattern and context API. Create sophisticated features like multi-level dropdowns, nested accordions, or custom form controls by composing atomic components together.

---

## 📦 Available Components
Expand Down Expand Up @@ -268,6 +272,85 @@ For more control, you can use the Bond system directly:
</div>
```

### Advanced Usage With Composition

This example demonstrates the power of component composition by combining `Dropdown`, `Input`, and animation capabilities to create a searchable multi-select dropdown with smooth transitions:

```svelte
<script lang="ts">
import { Dropdown, Input, Root, filter } from '@svelte-atoms/core';
import { flip } from 'svelte/animate';

// Sample data
let data = [
{ id: 1, value: 'apple', text: 'Apple' },
{ id: 2, value: 'banana', text: 'Banana' },
{ id: 3, value: 'cherry', text: 'Cherry' },
{ id: 4, value: 'date', text: 'Date' },
{ id: 5, value: 'elderberry', text: 'Elderberry' }
];

let open = $state(false);
// Filter items based on search query
const dd = filter(
() => data,
(query, item) => item.text.toLowerCase().includes(query.toLowerCase())
);
</script>

<Root class="items-center justify-center p-4">
<!-- Multi-select dropdown with search functionality -->
<Dropdown.Root
bind:open
multiple
keys={data.map((item) => item.value)}
onquerychange={(q) => (dd.query = q)}
>
{#snippet children({ dropdown })}
<!-- Compose Dropdown.Trigger with Input.Root for a custom trigger -->
<Dropdown.Trigger
base={Input.Root}
class="h-auto min-h-12 max-w-sm min-w-sm items-center gap-2 rounded-sm px-4 transition-colors duration-200"
onclick={(ev) => {
ev.preventDefault();

dropdown.state.open();
}}
>
<!-- Display selected values with animation -->
{#each dropdown?.state?.selectedItems ?? [] as item (item.id)}
<div animate:flip={{ duration: 200 }}>
<ADropdown.Value value={item.value} class="text-foreground/80">
{item.text}
</ADropdown.Value>
</div>
{/each}

<!-- Inline search input within the trigger -->
<Dropdown.Query class="flex-1 px-1" placeholder="Search for fruits..." />
</Dropdown.Trigger>

<!-- Dropdown list with filtered items -->
<Dropdown.List>
{#each dd.current as item (item.id)}
<div animate:flip={{ duration: 200 }}>
<Dropdown.Item value={item.value}>{item.text}</Dropdown.Item>
</div>
{/each}
</Dropdown.List>
{/snippet}
</Dropdown.Root>
</Root>
```

**Key composition features demonstrated:**

- **Component Fusion**: Using `base={Input.Root}` to compose Dropdown.Trigger with Input styling and behavior
- **Snippet Patterns**: Accessing internal state through snippets for custom rendering
- **Reactive Filtering**: Combining search query state with reactive effects for real-time filtering
- **Smooth Animations**: Using Svelte's `flip` animation for seamless list transitions
- **Multi-Select State**: Managing complex selection state through the Bond pattern

---

## 📖 Documentation
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@svelte-atoms/core",
"version": "1.0.0-alpha.19",
"version": "1.0.0-alpha.20",
"description": "A modular, accessible, and extensible Svelte UI component library.",
"repository": {
"type": "git",
Expand Down
4 changes: 2 additions & 2 deletions src/lib/components/atom/html-atom.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,11 @@
return cls.replace('$preset', cn(preset?.class));
}

return toClassValue.apply(bond, [cls]);
return toClassValue.apply(bond, [cls, bond]);
});
}

return [preset?.class ?? '', toClassValue.apply(bond, [klass])];
return [preset?.class ?? '', toClassValue.apply(bond, [klass, bond])];
});

const _base = $derived(base ?? preset?.base);
Expand Down
12 changes: 3 additions & 9 deletions src/lib/components/dropdown/dropdown-query.svelte
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
<script lang="ts" generics="T extends keyof HTMLElementTagNameMap = 'div', S extends Shell = Shell">
import { onMount, type Component } from 'svelte';
import { onMount } from 'svelte';
import { DropdownBond } from './bond.svelte';
import { Input } from '$svelte-atoms/core/components/input';
import { toClassValue, cn } from '$svelte-atoms/core/utils';

const bond = DropdownBond.get() as DropdownBond;

Expand Down Expand Up @@ -41,14 +40,9 @@

<Input.Value
bind:value={bond.state.query}
preset="dropdown.query"
class={['inline-flex w-min flex-1 py-1', '$preset', klass]}
{bond}
onpointerdown={(ev) => {
ev.stopPropagation();

bond.state.open();
}}
preset="dropdown.query"
class={['inline-flex h-auto w-auto flex-1 py-1', '$preset', klass]}
onmount={onmount?.bind(bond.state)}
ondestroy={ondestroy?.bind(bond.state)}
enter={enter?.bind(bond.state)}
Expand Down
67 changes: 38 additions & 29 deletions src/lib/components/dropdown/dropdown.stories.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import { Dropdown as ADropdown } from '.';
import { Root as DropdownRoot } from './atoms';
import Root from '$svelte-atoms/core/components/root/root.svelte';
import { Input } from '$svelte-atoms/core/components/input';
import { flip } from 'svelte/animate';
Expand All @@ -24,10 +23,11 @@
let open = $state(false);

const data = $state([
{ id: '1', value: 'ar', text: 'Arabic' },
{ id: '2', value: 'en', text: 'English' },
{ id: '3', value: 'sp', text: 'Spanish' },
{ id: '4', value: 'it', text: 'Italian' }
{ id: 1, value: 'apple', text: 'Apple' },
{ id: 2, value: 'banana', text: 'Banana' },
{ id: 3, value: 'cherry', text: 'Cherry' },
{ id: 4, value: 'date', text: 'Date' },
{ id: 5, value: 'elderberry', text: 'Elderberry' }
]);

const dd = filter(
Expand All @@ -38,37 +38,46 @@

<Story name="Dropdown" args={{}}>
<Root class="items-center justify-center p-4">
<!-- Multi-select dropdown with search functionality -->
<ADropdown.Root
bind:open
keys={data.map((item) => item.value)}
multiple
onquerychange={(q) => (dd.query = q)}
>
<ADropdown.Trigger
base={Input.Root}
class="hover:bg-foreground/5 active:bg-foreground/10 max-w-sm min-w-sm items-center gap-2 rounded-sm px-4 transition-colors duration-200"
>
<ADropdown.Values>
{#snippet children({ items })}
{#each items as item (item.id)}
<div animate:flip={{ duration: 200 }}>
<ADropdown.Value value={item.value} class="text-foreground/80"
>{item.text} - {item.value}</ADropdown.Value
>
</div>
{/each}
{/snippet}
</ADropdown.Values>
{#snippet children({ dropdown })}
<!-- Compose ADropdown.Trigger with Input.Root for a custom trigger -->
<ADropdown.Trigger
base={Input.Root}
class="h-auto min-h-12 max-w-sm min-w-sm items-center gap-2 rounded-sm px-4 transition-colors duration-200"
onclick={(ev) => {
ev.preventDefault();

<ADropdown.Query class="flex-1 px-1" placeholder={'Search for items'} />
</ADropdown.Trigger>
<ADropdown.List>
{#each dd.current as item (item.id)}
<div animate:flip={{ duration: 200 }}>
<ADropdown.Item value={item.value}>{item.text}</ADropdown.Item>
</div>
{/each}
</ADropdown.List>
dropdown.state.open();
}}
>
<!-- Display selected values with animation -->
{#each dropdown?.state?.selectedItems ?? [] as item (item.id)}
<div animate:flip={{ duration: 200 }}>
<ADropdown.Value value={item.value} class="text-foreground/80">
{item.text}
</ADropdown.Value>
</div>
{/each}

<!-- Inline search input within the trigger -->
<ADropdown.Query class="flex-1 px-1" placeholder="Search for fruits..." />
</ADropdown.Trigger>

<!-- ADropdown list with filtered items -->
<ADropdown.List>
{#each dd.current as item (item.id)}
<div animate:flip={{ duration: 200 }}>
<ADropdown.Item value={item.value}>{item.text}</ADropdown.Item>
</div>
{/each}
</ADropdown.List>
{/snippet}
</ADropdown.Root>
</Root>
</Story>
3 changes: 3 additions & 0 deletions src/lib/components/dropdown/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
export * as Dropdown from './atoms';

export {
DropdownBond,
type DropdownBondElements,
DropdownBondState,
type DropdownStateProps
} from './bond.svelte';

export { filter } from './runes.svelte';
10 changes: 5 additions & 5 deletions src/lib/components/input/input-value.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
export type InputPortals = 'input.l0' | 'input.l1' | 'input.l2' | 'input.l3';

export type InputProps = {
value?: string;
value?: ClassValue;
files?: File[];
date?: Date | null;
number?: number;
Expand All @@ -19,7 +19,7 @@
import type { HTMLInputTypeAttribute } from 'svelte/elements';
import { on } from '$svelte-atoms/core/attachments/event.svelte';
import { getPreset } from '$svelte-atoms/core/context';
import { toClassValue } from '$svelte-atoms/core/utils';
import { cn, toClassValue, type ClassValue } from '$svelte-atoms/core/utils';
import type { PresetModuleName } from '$svelte-atoms/core/context/preset.svelte';
import { InputBond } from './bond.svelte';

Expand Down Expand Up @@ -94,11 +94,11 @@
}
}
}
class={[
class={cn(
'h-full w-full flex-1 bg-transparent px-2 leading-1 outline-none',
preset?.class,
toClassValue(bond, klass)
]}
toClassValue(klass, bond)
)}
onchange={handleChange}
oninput={handleInput}
{...valueProps}
Expand Down