diff --git a/README.md b/README.md index ec3dcf4..fd2dfb1 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ Our comprehensive collection of UI components with implementation status: | **Switch** | Toggle controls | ❌ | | [**Textarea**](docs/components/textarea.md) | Multi-line text inputs | ✅ | | [**Form**](docs/components/form.md) | Form validation and state management | ✅ | -| **DatePicker** | Date selection component | ❌ | +| **DatePicker** | Date selection component | ✅ | | **TimePicker** | Time selection component | ❌ | | **FileUpload** | File upload component | ❌ | | **ColorPicker** | Color selection component | ❌ | @@ -99,7 +99,8 @@ Our comprehensive collection of UI components with implementation status: | **Progress** | Progress indicators | ❌ | | **Skeleton** | Loading placeholders | ❌ | | **Timeline** | Event timeline display | ❌ | -| **Calendar** | Date display component | ❌ | +| **Calendar** | Date display component | ✅ | +| **QRCode** | QR code generator | ✅ | ### Overlays & Feedback @@ -351,6 +352,216 @@ This example demonstrates the power of component composition by combining `Dropd - **Smooth Animations**: Using Svelte's `flip` animation for seamless list transitions - **Multi-Select State**: Managing complex selection state through the Bond pattern +### Creating Custom Variants + +@svelte-atoms/core provides a powerful variant system using `defineVariants()` that allows you to create type-safe, reusable component variations with support for compound variants, defaults, and bond state integration. + +#### Basic Variant Definition + +```typescript +import { defineVariants, type VariantPropsType } from '@svelte-atoms/core/utils'; + +const buttonVariants = defineVariants({ + class: 'inline-flex items-center justify-center rounded-md font-medium transition-colors', + variants: { + variant: { + primary: 'bg-blue-500 text-white hover:bg-blue-600', + secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300', + ghost: 'hover:bg-gray-100' + }, + size: { + sm: 'h-8 px-3 text-sm', + md: 'h-10 px-4', + lg: 'h-12 px-6 text-lg' + } + }, + compounds: [ + { + variant: 'primary', + size: 'lg', + class: 'shadow-md font-semibold' + } + ], + defaults: { + variant: 'primary', + size: 'md' + } +}); + +// Extract type-safe props +type ButtonVariantProps = VariantPropsType; +``` + +#### Local vs Global Variants + +**Local Variants** - Define variants directly in your component: + +```svelte + + + + {@render children?.()} + +``` + +**Global Variants** - Define variants in your theme/preset configuration: + +```typescript +// +layout.svelte or theme configuration +import { setPreset } from '@svelte-atoms/core/context'; + +setPreset({ + button: () => ({ + class: 'inline-flex items-center justify-center rounded-md font-medium transition-colors', + variants: { + variant: { + default: { + class: 'bg-primary text-primary-foreground hover:bg-primary/90' + }, + destructive: { + class: 'bg-destructive text-destructive-foreground hover:bg-destructive/90' + }, + outline: { + class: 'border border-input bg-background hover:bg-accent' + } + }, + size: { + default: 'h-10 px-4 py-2', + sm: 'h-9 px-3', + lg: 'h-11 px-8' + } + }, + compounds: [ + { + variant: 'default', + size: 'lg', + class: 'text-base font-semibold' + } + ], + defaults: { + variant: 'default', + size: 'default' + } + }) +}); +``` + +#### Extending Global Variants + +Combine global presets with local extensions: + +```svelte + + + + {@render children?.()} + +``` + +#### Bond-Reactive Variants + +Variants can react to component state through the Bond pattern: + +```typescript +const accordionVariants = defineVariants({ + class: 'border rounded-md transition-all', + variants: { + state: { + open: (bond) => ({ + class: bond?.state?.isOpen ? 'bg-blue-50 border-blue-200' : 'bg-white', + 'aria-expanded': bond?.state?.isOpen, + 'data-state': bond?.state?.isOpen ? 'open' : 'closed' + }) + } + } +}); + +// Usage with bond +const bond = AccordionBond.get(); +const variantProps = $derived(accordionVariants(bond, { state: 'open' })); +``` + +**Variant Features:** + +- ✅ **Type Safety** - Automatic TypeScript inference +- ✅ **Compound Variants** - Apply styles when multiple conditions match +- ✅ **Default Values** - Specify fallback variant values +- ✅ **Bond Integration** - Access component state for reactive styling +- ✅ **Return Attributes** - Not just classes, any HTML attributes +- ✅ **Extensible** - Combine global presets with local variants + --- ## 📖 Documentation diff --git a/bun.lock b/bun.lock index ff359c9..679cee6 100644 --- a/bun.lock +++ b/bun.lock @@ -1,16 +1,19 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "@svelte-atoms/core", "dependencies": { "@floating-ui/dom": "^1.7.0", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "es-toolkit": "^1.37.2", "gsap": "^3.13.0", "motion": "^12.23.22", "nanoid": "^5.1.5", "tailwind-merge": "^3.2.0", + "uqr": "^0.1.2", }, "devDependencies": { "@chromatic-com/storybook": "^4.1.1", @@ -455,6 +458,8 @@ "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "dedent": ["dedent@1.7.0", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ=="], @@ -593,7 +598,7 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], @@ -899,6 +904,8 @@ "unplugin": ["unplugin@1.16.1", "", { "dependencies": { "acorn": "^8.14.0", "webpack-virtual-modules": "^0.6.2" } }, "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w=="], + "uqr": ["uqr@0.1.2", "", {}, "sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA=="], + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], @@ -943,6 +950,8 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], @@ -989,8 +998,6 @@ "rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], - "svelte/esrap": ["esrap@2.1.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA=="], "svelte-ast-print/esrap": ["esrap@1.2.2", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "@types/estree": "^1.0.1" } }, "sha512-F2pSJklxx1BlQIQgooczXCPHmcWpn6EsP5oo73LQfonG9fIlIENQ8vMmfGXeojP9MrkzUNAfyU5vdFlR9shHAw=="], diff --git a/llm/variants.md b/llm/variants.md index c9450f2..b62043d 100644 --- a/llm/variants.md +++ b/llm/variants.md @@ -195,51 +195,67 @@ setPreset({ - Local variant classes (ghost, loading) - Merged intelligently - local overrides preset -```typescript -import { defineVariants } from '@svelte-atoms/core/utils'; +## Basic Usage -const buttonVariants = defineVariants({ - class: 'rounded-md font-medium transition-colors', - variants: { - variant: { - primary: 'bg-blue-500 text-white hover:bg-blue-600', - secondary: 'bg-gray-500 text-white hover:bg-gray-600', - danger: 'bg-red-500 text-white hover:bg-red-600' +**Key Pattern:** `defineVariants()` returns a **function** that you pass to `HtmlAtom`. The component calls this function internally with bond and props. + +```svelte + - + + + {@render children?.()} ``` +**How it works:** + +1. `defineVariants(config)` → returns a function +2. You pass this function to `HtmlAtom` via `{variants}` +3. `HtmlAtom` internally calls `variants(bond, restProps)` +4. The function returns `{ class: [...], ...attributes }` +5. `HtmlAtom` applies these to the rendered element + ## Key Features ### 1. Access to Component State (Bond) @@ -264,9 +280,12 @@ const accordionVariants = defineVariants({ } }); -// Usage: -const bond = AccordionBond.get(); -const variantProps = accordionVariants(bond, { state: 'open' }); +// Usage in component: +let { state = 'open', ...props } = $props(); + + + {@render children?.()} + ``` ### 2. Return Both Classes and Attributes @@ -291,7 +310,7 @@ const buttonVariants = defineVariants({ } }); -// Returns: { class: '...', 'aria-label': '...', 'data-variant': '...', ... } +// When called by HtmlAtom, returns: { class: [...], 'aria-label': '...', 'data-variant': '...', ... } ``` ### 3. Compound Variants @@ -353,8 +372,10 @@ const buttonVariants = defineVariants({ } }); -// Call without props - uses defaults -buttonVariants(null); // Returns variant='primary', size='md' +// Pass to HtmlAtom - uses defaults when props not provided + + {@render children?.()} + ``` ## Complete Examples @@ -419,9 +440,41 @@ setPreset({ ``` -### Example 2: Local Variants Only +### Example 2: Local Variants with HtmlAtom + +```svelte + + + + {@render children?.()} + +``` -````svelte ### Example 3: Extending Preset with Local Variants Combine global preset variants with component-specific variants: @@ -468,51 +521,50 @@ Combine global preset variants with component-specific variants: ```` -### Example 4: Reactive Variants with Bond State +### Example 4: Alert Component with Compound Variants - - -{@render children?.()} + + {@render children?.()} +``` ```` @@ -547,14 +599,11 @@ Combine global preset variants with component-specific variants: } }); - const bond = AccordionBond.get(); - - // Automatically reactive - updates when bond state changes - const variantProps = $derived(accordionVariants(bond, { state: 'open' })); + let { state = 'open', ...props } = $props(); - - {@render children?.({ accordion: bond })} + + {@render children?.()} ```` @@ -612,12 +661,12 @@ Combine global preset variants with component-specific variants: }); let { variant, size, ...props } = $props(); - - const bond = null; // or get from context if needed - const variantProps = buttonVariants(bond, { variant, size }); -Click me + + + Click me + ``` ### Benefits After Migration @@ -658,17 +707,21 @@ export const cardVariants = defineVariants({ }); ``` -### 2. Use $derived for Reactive Variants +### 2. Pass Variants Function to Components -When using bond state, wrap the variant call in `$derived`: +Always pass the variant function (not the result) to `HtmlAtom`: ```svelte + + + + {@render children?.()} + ``` ### 3. Extend with Additional Classes @@ -677,11 +730,12 @@ Merge variant classes with custom classes: ```svelte - + + {@render children?.()} + ``` ### 4. Type Props from Variants @@ -698,6 +752,499 @@ type ButtonProps = VariantPropsType & { }; ``` +## Creating Variants: Local vs Global + +### Local Variants (Component-Specific) + +Create variants directly in your component file when they're only used in one place: + +```svelte + + + + + {@render children?.()} + +``` + +**Use local variants when:** + +- The component is unique and won't be reused elsewhere +- You need quick prototyping +- Variants are tightly coupled to the component logic + +### Global Variants (Preset-Based) + +Define variants globally in your theme/preset configuration for reusable components: + +```typescript +// +layout.svelte or theme.svelte +import { setPreset } from '@svelte-atoms/core/context'; + +setPreset({ + // Global button variants + button: () => ({ + class: + 'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2', + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', + outline: 'border border-input bg-background hover:bg-accent', + secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline' + }, + size: { + default: 'h-10 px-4 py-2', + sm: 'h-9 px-3', + lg: 'h-11 px-8', + icon: 'h-10 w-10' + } + }, + compounds: [ + { + variant: 'default', + size: 'lg', + class: 'text-base font-semibold' + } + ], + defaults: { + variant: 'default', + size: 'default' + } + }), + + // Global card variants + card: () => ({ + class: 'rounded-lg border bg-card text-card-foreground shadow-sm', + variants: { + variant: { + default: 'border-border', + elevated: 'shadow-lg', + outlined: 'border-2' + } + }, + defaults: { + variant: 'default' + } + }) +}); +``` + +```svelte + + + + + as="button" + {variant} + {size} + {disabled} + class={klass} + {...props} +> + {@render children?.()} + +``` + +**Use global variants when:** + +- Building a design system with consistent styling +- Components are used across multiple pages/features +- You want centralized theme control +- You need to support theme switching + +### Extending Global Variants Locally + +Combine the best of both worlds - use global presets as a base and extend with local variants: + +```svelte + + + + + variants={extendedVariants} + as="button" + {variant} + {size} + {animated} + {shadow} + {disabled} + class={klass} + {...props} +> + {@render children?.()} + +``` + +## Type-Safe Component Props with Variants + +### Method 1: Manual Type Definition (Preset-based) + +When using presets, manually define the variant types based on your preset configuration: + +```svelte + + + + {@render children?.()} + +``` + +### Method 2: Extract Types from defineVariants + +For local variants, use `VariantPropsType` to automatically extract types: + +```svelte + + + + {@render children?.()} + +``` + +### Method 3: Shared Variant Types + +Create reusable variant type definitions: + +```typescript +// types/button.ts +import { defineVariants, type VariantPropsType } from '@svelte-atoms/core/utils'; + +export const buttonVariants = defineVariants({ + class: 'inline-flex items-center justify-center rounded-md font-medium', + variants: { + variant: { + primary: 'bg-blue-500 text-white', + secondary: 'bg-gray-200 text-gray-900', + ghost: 'hover:bg-gray-100' + }, + size: { + sm: 'h-8 px-3 text-sm', + md: 'h-10 px-4', + lg: 'h-12 px-6 text-lg' + } + }, + defaults: { + variant: 'primary', + size: 'md' + } +}); + +// Export the variant types +export type ButtonVariantProps = VariantPropsType; + +// Export full component props +export type ButtonProps = ButtonVariantProps & { + disabled?: boolean; + onclick?: (e: MouseEvent) => void; + class?: string; +}; +``` + +```svelte + + + + + {@render children?.()} + +``` + +### Method 4: Component with Bond State Types + +For components using the Bond pattern: + +```typescript +// accordion-item.svelte + + + + {@render children?.()} + +``` + +### Best Practices for Typed Variants + +1. **Always extract types from defineVariants** + + ```typescript + const variants = defineVariants({...}); + type VariantProps = VariantPropsType; + ``` + +2. **Extend variant types with component props** + + ```typescript + type ComponentProps = VariantProps & { + disabled?: boolean; + onclick?: () => void; + }; + ``` + +3. **Use Omit for bond-driven variants** + + ```typescript + // Remove 'state' from props since it's driven by bond + type Props = Omit & { ... }; + ``` + +4. **Share types across related components** + + ```typescript + // types/card.ts + export type CardVariantProps = VariantPropsType; + export type CardHeaderProps = { class?: string }; + export type CardBodyProps = { class?: string }; + ``` + +5. **Document variant options in JSDoc** + ```typescript + /** + * Button component with multiple variants + * @param variant - Visual style: 'primary' | 'secondary' | 'ghost' + * @param size - Size variant: 'sm' | 'md' | 'lg' + */ + type ButtonProps = VariantPropsType & {...}; + ``` + ## Summary `defineVariants()` provides: @@ -707,6 +1254,8 @@ type ButtonProps = VariantPropsType & { ✅ **Reactive** - Access bond state for dynamic styling ✅ **Powerful** - Base classes, compound variants, defaults ✅ **Flexible** - Return both classes and attributes -✅ **Clean** - No manual object merging or conditionals +✅ **Clean** - No manual object merging or conditionals +✅ **Extensible** - Combine global presets with local variants +✅ **Type extraction** - Use `VariantPropsType` for automatic type inference Inspired by Class Variance Authority but integrated with @svelte-atoms/core's bond system. diff --git a/package.json b/package.json index fd4c844..8641eee 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,16 @@ "svelte": "./dist/components/button/index.js", "default": "./dist/components/button/index.js" }, + "./calendar": { + "types": "./dist/components/calendar/index.d.ts", + "svelte": "./dist/components/calendar/index.js", + "default": "./dist/components/calendar/index.js" + }, + "./calendar/atoms": { + "types": "./dist/components/calendar/atoms.d.ts", + "svelte": "./dist/components/calendar/atoms.js", + "default": "./dist/components/calendar/atoms.js" + }, "./card": { "types": "./dist/components/card/index.d.ts", "svelte": "./dist/components/card/index.js", @@ -127,6 +137,16 @@ "svelte": "./dist/components/combobox/atoms.js", "default": "./dist/components/combobox/atoms.js" }, + "./date-picker": { + "types": "./dist/components/date-picker/index.d.ts", + "svelte": "./dist/components/date-picker/index.js", + "default": "./dist/components/date-picker/index.js" + }, + "./date-picker/atoms": { + "types": "./dist/components/date-picker/atoms.d.ts", + "svelte": "./dist/components/date-picker/atoms.js", + "default": "./dist/components/date-picker/atoms.js" + }, "./datagrid": { "types": "./dist/components/datagrid/index.d.ts", "svelte": "./dist/components/datagrid/index.js", @@ -247,6 +267,11 @@ "svelte": "./dist/components/portal/atoms.js", "default": "./dist/components/portal/atoms.js" }, + "./qr-code": { + "types": "./dist/components/qr-code/index.d.ts", + "svelte": "./dist/components/qr-code/index.js", + "default": "./dist/components/qr-code/index.js" + }, "./radio": { "types": "./dist/components/radio/index.d.ts", "svelte": "./dist/components/radio/index.js", @@ -428,10 +453,12 @@ "dependencies": { "@floating-ui/dom": "^1.7.0", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "es-toolkit": "^1.37.2", "gsap": "^3.13.0", "motion": "^12.23.22", "nanoid": "^5.1.5", - "tailwind-merge": "^3.2.0" + "tailwind-merge": "^3.2.0", + "uqr": "^0.1.2" } } diff --git a/playwright.config.ts b/playwright.config.ts index f6c81af..0981b34 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,9 +1,24 @@ -import { defineConfig } from '@playwright/test'; +import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ webServer: { command: 'npm run build && npm run preview', - port: 4173 + port: 4173, + reuseExistingServer: !process.env.CI }, - testDir: 'e2e' + testDir: 'e2e', + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? 'html' : 'list', + use: { + trace: 'on-first-retry', + screenshot: 'only-on-failure' + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] } + } + ] }); diff --git a/src/lib/components/alert/alert-actions.svelte b/src/lib/components/alert/alert-actions.svelte index 2ce0070..4d5a811 100644 --- a/src/lib/components/alert/alert-actions.svelte +++ b/src/lib/components/alert/alert-actions.svelte @@ -10,6 +10,7 @@ let { class: klass = '', + preset = 'alert.actions', children = undefined, onmount = undefined, ondestroy = undefined, @@ -28,7 +29,7 @@ import type { HTMLAttributes } from 'svelte/elements'; - import { AlertBond } from './bond.svelte'; import { HtmlAtom, type Base } from '$svelte-atoms/core/components/atom'; + import { AlertBond } from './bond.svelte'; import type { AlertCloseButtonProps } from './types'; + import { Icon } from '../icon'; type Element = HTMLElementTagNameMap[E]; @@ -14,6 +15,7 @@ let { class: klass = '', as = 'button' as E, + preset = 'alert.close-button', children = undefined, onmount = undefined, ondestroy = undefined, @@ -36,16 +38,9 @@ - {@render children?.({ alert: bond! })} - {#if !children} - - - + {#if children} + {@render children({ alert: bond! })} + {:else} + + + + + {/if} {/if} diff --git a/src/lib/components/alert/alert-content.svelte b/src/lib/components/alert/alert-content.svelte index 598577e..00aeb3a 100644 --- a/src/lib/components/alert/alert-content.svelte +++ b/src/lib/components/alert/alert-content.svelte @@ -10,6 +10,7 @@ let { class: klass = '', + preset = 'alert.content', children = undefined, onmount = undefined, ondestroy = undefined, @@ -28,7 +29,7 @@ - - - {#snippet children(args)} - +{#snippet alertLayout({ children, class: klass, ...args })} + {@const gridTemplateAreas = `"icon title close-button" ". description description" "content content content" "actions actions actions"`} + {@const gridTemplateColumns = `auto 1fr auto`} + +
+ {@render children?.()} +
+{/snippet} + + + +
+

Alert Variants

+ + + + + + + + + + New Feature Available + + We've added dark mode support to your dashboard. Try it out in the settings panel. + + + + + + + + + + + + + Changes Saved Successfully + + Your profile settings have been updated and synced across all devices. + + + + + + + + + + + + + + Storage Almost Full + + You're using 90% of your storage quota. Consider upgrading your plan or removing unused + files. + + + + + + + + + + + + + + Payment Failed + + We couldn't process your payment. Please check your payment method and try again. + + + +
+
+
+ + + +
+

Dismissible Alerts

+ + + + + + + + + Cookie Preferences + + We use cookies to enhance your experience. You can manage your preferences in settings. + + + + + + + + + + + + + {#if dismissedState} + + {/if} + + + + + + + + + + Beta Feature Warning + + You're using a beta feature. Some functionality may be unstable or change without notice. + + + + + + + + + + + +
+
+
+ + + +
+

Alerts with Action Buttons

+ + + + + + + + + System Update Available + + A new version is ready to install. Update now to get the latest features and security + improvements. + + + + + + + + + + + + + + + + + Verification Required + + Your account needs verification before you can access premium features. + + + + + + + + + + + + + + + + + + + + + + + + Backup Completed + + All your data has been backed up successfully. Last backup: just now. + + + + + + +
+
+
+ + + +
+

Minimal Alerts

+ - - - Info Alert - - This is an informational message that provides context about something. - - - - - {/snippet} + Quick tip: Press Ctrl+K to open the command palette. + + + + Your changes have been saved automatically. + + + + Your session will expire in 5 minutes. + + + + + Connection lost. Attempting to reconnect... + +
+
+
+ + + +
+

Real-World Use Cases

+ + + + + + + + + + Successfully Subscribed! + + You're now subscribed to our newsletter. Check your inbox for a confirmation email. + + + + + + + + + + + + + + + + + + + + + API Rate Limit Warning + + You've used 450 of 500 API calls this hour. Rate limit resets in 23 minutes. + + + + + + + + + + + + + + + + Scheduled Maintenance + + Our services will be undergoing maintenance on Dec 15, 2025 from 2:00 AM to 4:00 AM UTC. + Some features may be temporarily unavailable. + + + + + + + + + + + + + + + + Unusual Login Detected + + We detected a login from a new device in San Francisco, CA. If this wasn't you, secure + your account immediately. + + + + + + + + + + + + + + + + + + + + + + + + + + + Trial Ending Soon + + Your free trial ends in 3 days. Upgrade now to keep access to premium features and avoid + any interruption. + + + + + + + + + + + + + + + +
+
diff --git a/src/lib/components/atom/html-atom.svelte b/src/lib/components/atom/html-atom.svelte index 2d0e0c5..102b4ff 100644 --- a/src/lib/components/atom/html-atom.svelte +++ b/src/lib/components/atom/html-atom.svelte @@ -1,17 +1,14 @@ -{#if isSnippet} - {@render snippet({ class: _klass, as: _as, base: _base, children, ..._restProps })} -{:else} - - {@render children?.()} - -{/if} + + {@render children?.()} + diff --git a/src/lib/components/atom/snippet-renderer.svelte b/src/lib/components/atom/snippet-renderer.svelte new file mode 100644 index 0000000..26e6f0d --- /dev/null +++ b/src/lib/components/atom/snippet-renderer.svelte @@ -0,0 +1,5 @@ + + +{@render snippet?.({ ...restProps })} diff --git a/src/lib/components/button/button.stories.svelte b/src/lib/components/button/button.stories.svelte index b33e62a..d4374cd 100644 --- a/src/lib/components/button/button.stories.svelte +++ b/src/lib/components/button/button.stories.svelte @@ -5,7 +5,17 @@ import { defineVariants } from '$svelte-atoms/core/utils/variant'; const { Story } = defineMeta({ - title: 'ATOMS/Button' + title: 'ATOMS/Button', + argTypes: { + variant: { + control: 'select', + options: ['primary', 'secondary', 'destructive', 'outline', 'ghost'], + description: 'Button variant style', + table: { + defaultValue: { summary: 'primary' } + } + } + } }); @@ -29,7 +39,8 @@ 'bg-transparent hover:bg-foreground/5 active:bg-foreground/10 border border-border text-foreground' }, ghost: { - class: 'hover:bg-accent hover:text-accent-foreground' + class: + 'bg-transparent text-foreground hover:bg-foreground/5 active:bg-foreground/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2' } } }, @@ -41,17 +52,9 @@ - - {#snippet children({ args })} - Clicke me - {/snippet} - - - - - - {#snippet children({ args })} + {#snippet template(args)} + Clicke me - {/snippet} - + + {/snippet} diff --git a/src/lib/components/calendar/atoms.ts b/src/lib/components/calendar/atoms.ts new file mode 100644 index 0000000..6ae184f --- /dev/null +++ b/src/lib/components/calendar/atoms.ts @@ -0,0 +1,5 @@ +export { default as Root } from './calendar-root.svelte'; +export { default as Body } from './calendar-body.svelte'; +export { default as Day } from './calendar-day.svelte'; +export { default as Header } from './calendar-header.svelte'; +export { default as WeekDay } from './calendar-week-day.svelte'; diff --git a/src/lib/components/calendar/bond.svelte.ts b/src/lib/components/calendar/bond.svelte.ts new file mode 100644 index 0000000..3bd1aef --- /dev/null +++ b/src/lib/components/calendar/bond.svelte.ts @@ -0,0 +1,183 @@ +import { Bond, BondState, type BondStateProps } from '$svelte-atoms/core/shared/bond.svelte'; +import { getContext, setContext } from 'svelte'; +import { createAttachmentKey } from 'svelte/attachments'; +import type { CalendarRange, Day, Month } from './types'; + +export type CalendarBondProps = BondStateProps & { + value?: Date; + range: CalendarRange; + start?: Date; + end?: Date; + min?: Date; + max?: Date; + pivote?: Date; + disabled?: boolean; + type?: 'range' | 'single'; + currentMonth?: Month; + previousMonth?: Month; + nextMonth?: Month; + extend?: Record; +}; + +export type CalendarBondElements = { + root: HTMLElement; + body: HTMLElement; + day: HTMLElement; + weekDay: HTMLElement; + header: HTMLElement; +}; + +export class CalendarBond< + Props extends CalendarBondProps = CalendarBondProps, + State extends CalendarBondState = CalendarBondState +> extends Bond { + static CONTEXT_KEY = '@atoms/context/calendar'; + + constructor(s: State) { + super(s); + } + + share(): this { + return CalendarBond.set(this) as this; + } + + root() { + return { + id: `calendar-root-${this.id}`, + 'aria-label': 'Calendar', + 'aria-disabled': this.state.props.disabled ?? false, + role: 'application', + [createAttachmentKey()]: (node: HTMLElement) => { + this.elements.root = node; + } + }; + } + + body() { + return { + id: `calendar-month-${this.id}`, + role: 'grid', + 'aria-labelledby': `calendar-month-label-${this.id}`, + [createAttachmentKey()]: (node: HTMLElement) => { + this.elements.body = node; + } + }; + } + + day(day: Day) { + return { + id: `calendar-day-${this.id}-${day.id}`, + role: 'gridcell', + 'aria-selected': this.state.isDaySelected(day), + 'aria-disabled': day.disabled, + tabindex: day.disabled ? -1 : 0 + }; + } + + weekDay() { + return { + id: `calendar-weekday-${this.id}`, + role: 'columnheader' + }; + } + + header() { + return { + id: `calendar-weekdays-${this.id}`, + role: 'row', + [createAttachmentKey()]: (node: HTMLElement) => { + this.elements.header = node; + } + }; + } + + static get(): CalendarBond | undefined { + return getContext(this.CONTEXT_KEY); + } + + static set(bond: CalendarBond): CalendarBond { + return setContext(this.CONTEXT_KEY, bond); + } +} + +export class CalendarBondState extends BondState { + constructor(props: () => Props) { + super(props); + } + + selectDate(date: Date) { + // if (this.props.range) { + // } else { + // this.props.value = date; + // } + + if (!this.props.start) { + this.props.range[0] = date; + } else if (!this.props.end) { + this.props.range[1] = date; + } else { + this.props.range[0] = date; + this.props.range[1] = undefined; + } + + this.props.range = [...this.props.range]; + } + + selectStart(date: Date) { + this.props.range[0] = date; + this.props.range = [...this.props.range]; + } + + selectEnd(date: Date) { + this.props.range[1] = date; + this.props.range = [...this.props.range]; + } + + unselect() { + this.props.range = [undefined, undefined]; + this.props.range = [...this.props.range]; + } + + unselectStart() { + this.props.range[0] = undefined; + this.props.range = [...this.props.range]; + } + + unselectEnd() { + this.props.range[1] = undefined; + this.props.range = [...this.props.range]; + } + + nextMonth() { + if (this.props.pivote) { + const current = this.props.pivote; + this.props.pivote = new Date(current.getFullYear(), current.getMonth() + 1, 1); + } + } + + previousMonth() { + if (this.props.pivote) { + const current = this.props.pivote; + this.props.pivote = new Date(current.getFullYear(), current.getMonth() - 1, 1); + } + } + + isDaySelected(day: Day): boolean { + if (this.props.range) { + const start = this.props.range[0]; + const end = this.props.range[1]; + + if (!start) return false; + + const dayTime = day.date.getTime(); + const startTime = start.getTime(); + + if (!end) return dayTime === startTime; + + const endTime = end.getTime(); + return dayTime >= startTime && dayTime <= endTime; + } + + return this.props.value?.getTime() === day.date.getTime(); + } +} diff --git a/src/lib/components/calendar/calendar-body.svelte b/src/lib/components/calendar/calendar-body.svelte new file mode 100644 index 0000000..5cc318a --- /dev/null +++ b/src/lib/components/calendar/calendar-body.svelte @@ -0,0 +1,107 @@ + + + + {#each currentMonth?.days ?? [] as day (day.id)} + {#if children} + {@render children?.({ day })} + {:else} + { + console.log('day clicked', day.date); + calendarBond?.state.selectStart(new Date(day.date)); + }} + /> + {/if} + {/each} + diff --git a/src/lib/components/calendar/calendar-day.svelte b/src/lib/components/calendar/calendar-day.svelte new file mode 100644 index 0000000..5c89b03 --- /dev/null +++ b/src/lib/components/calendar/calendar-day.svelte @@ -0,0 +1,97 @@ + + + + {#if children} + {@render children({ + calendar: calendarBond! + })} + {:else} + {day.dayOfMonth} + {/if} + + + diff --git a/src/lib/components/calendar/calendar-header.svelte b/src/lib/components/calendar/calendar-header.svelte new file mode 100644 index 0000000..565ded8 --- /dev/null +++ b/src/lib/components/calendar/calendar-header.svelte @@ -0,0 +1,33 @@ + + + + {#each (currentMonth?.days ?? []).filter((d) => d.week == 1) as day} + {#if children} + {@render children?.(day)} + {:else} + {day.name} + {/if} + {/each} + diff --git a/src/lib/components/calendar/calendar-root.svelte b/src/lib/components/calendar/calendar-root.svelte new file mode 100644 index 0000000..8482e6e --- /dev/null +++ b/src/lib/components/calendar/calendar-root.svelte @@ -0,0 +1,208 @@ + + + + {@render children?.({ calendar: bond })} + diff --git a/src/lib/components/calendar/calendar-week-day.svelte b/src/lib/components/calendar/calendar-week-day.svelte new file mode 100644 index 0000000..19de290 --- /dev/null +++ b/src/lib/components/calendar/calendar-week-day.svelte @@ -0,0 +1,34 @@ + + + + {@render children?.()} + diff --git a/src/lib/components/calendar/calendar.css b/src/lib/components/calendar/calendar.css new file mode 100644 index 0000000..bfdcf3e --- /dev/null +++ b/src/lib/components/calendar/calendar.css @@ -0,0 +1,26 @@ +[data-atom='calendar-root'] { + display: grid; + grid-template-columns: repeat(7, 1fr); + grid-template-rows: auto 1fr; + /* + --icare-root-background-color: color-mix(in srgb, black 5%, white 100%); + --icare-root-background-color-hover: color-mix(in srgb, black 5%, white 100%); + --icare-root-background-color-active: color-mix(in srgb, black 5%, white 100%); + + --icare-root-text-color: black; + + --icare-disabled-background-color: black; + --icare-disabled-text-color: black; + + --icare-selected-background-color: black; + --icare-selected-text-color: white; + + --icare-today-background-color: black; + --icare-today-text-color: black; + + --icare-weekend-background-color: black; + --icare-weekend-text-color: black; + + --icare-offmonth-background-color: black; + --icare-offmonth-text-color: black; */ +} diff --git a/src/lib/components/calendar/calendar.stories.svelte b/src/lib/components/calendar/calendar.stories.svelte new file mode 100644 index 0000000..1eb60f8 --- /dev/null +++ b/src/lib/components/calendar/calendar.stories.svelte @@ -0,0 +1,36 @@ + + + + + + {#snippet children({ args })} + + {#snippet children({})} +
+ + + + {#snippet children({ day })} + + {/snippet} + + +
+ {/snippet} +
+ {/snippet} +
diff --git a/src/lib/components/calendar/index.ts b/src/lib/components/calendar/index.ts new file mode 100644 index 0000000..60e9e08 --- /dev/null +++ b/src/lib/components/calendar/index.ts @@ -0,0 +1,4 @@ +export * as Calendar from './atoms'; +export * from './bond.svelte'; +export * from './runes.svelte'; +export * from './types'; diff --git a/src/lib/components/calendar/runes.svelte.ts b/src/lib/components/calendar/runes.svelte.ts new file mode 100644 index 0000000..7167f05 --- /dev/null +++ b/src/lib/components/calendar/runes.svelte.ts @@ -0,0 +1,30 @@ +export function today(ms = 1000 * 60) { + // eslint-disable-next-line svelte/prefer-svelte-reactivity + let date = $state(new Date()); + + const date_readonly = $derived(date); + + let timeout_id: NodeJS.Timeout | undefined = undefined; + let interval_id: NodeJS.Timeout | undefined = undefined; + + $effect(() => { + timeout_id = setTimeout(() => { + interval_id = setInterval(() => { + // eslint-disable-next-line svelte/prefer-svelte-reactivity + date = new Date(); + }, ms); + // eslint-disable-next-line svelte/prefer-svelte-reactivity + }, 1000 - new Date().getMilliseconds()); + + return () => { + clearTimeout(timeout_id); + clearInterval(interval_id); + }; + }); + + return { + get current() { + return date_readonly; + } + }; +} diff --git a/src/lib/components/calendar/types.ts b/src/lib/components/calendar/types.ts new file mode 100644 index 0000000..d8f4124 --- /dev/null +++ b/src/lib/components/calendar/types.ts @@ -0,0 +1,78 @@ +import type { Snippet } from 'svelte'; +import type { CalendarContext } from './context'; +import type { Factory } from '$svelte-atoms/core/types'; +import type { CalendarBond } from './bond.svelte'; + +export type Day = { + id: number; + date: Date; + dayOfMonth: number; + offmonth: boolean; + today: boolean; + week: number; + month: number; + disabled: boolean; + weekend: boolean; + name: string; + fullname: string; + + readonly fromNextMonth: boolean; + readonly fromPreviousMonth: boolean; +}; + +export type Month = { + name: string; + fullname: string; + start: Date; + end: Date | undefined; + days: Day[]; +}; +export type CalendarRange = [Date | undefined, Date | undefined]; + +export interface CalendarRootProps { + class?: string; + preset?: string; + + value?: Date; + range?: CalendarRange; + + start?: Date; + end?: Date; + + min?: Date; + max?: Date; + + pivote?: Date; + + type?: 'range' | 'single'; + + extend?: Record; + + factory?: Factory; + + onchange?: (ev: CustomEvent, params: { range: CalendarRange; pivote: Date }) => void; + + children?: Snippet< + [ + { + calendar: CalendarBond; + } + ] + >; +} + +export interface CalendarDayProps { + class?: string; + preset?: string; + day: Day; + as?: string; + onclick?: () => void; + readonly element?: HTMLElement; + children?: Snippet< + [ + { + calendar: CalendarBond; + } + ] + >; +} diff --git a/src/lib/components/date-picker/atoms.ts b/src/lib/components/date-picker/atoms.ts new file mode 100644 index 0000000..6981e0e --- /dev/null +++ b/src/lib/components/date-picker/atoms.ts @@ -0,0 +1,7 @@ +export { Arrow, Indicator, Trigger } from '../popover/atoms'; +export { Body, Day, Header as WeekDays, WeekDay } from '../calendar/atoms'; +export { default as Root } from './date-picker-root.svelte'; +export { default as Calendar } from './date-picker-calendar.svelte'; +export { default as Header } from './date-picker-header.svelte'; +export { default as Years } from './date-picker-years.svelte'; +export { default as Months } from './date-picker-months.svelte'; diff --git a/src/lib/components/date-picker/bond.svelte.ts b/src/lib/components/date-picker/bond.svelte.ts new file mode 100644 index 0000000..25999b5 --- /dev/null +++ b/src/lib/components/date-picker/bond.svelte.ts @@ -0,0 +1,222 @@ +import { + PopoverBond, + PopoverState, + type PopoverDomElements, + type PopoverStateProps +} from '$svelte-atoms/core/components/popover/bond.svelte'; +import { getContext, setContext } from 'svelte'; +import { createAttachmentKey } from 'svelte/attachments'; +import type { CalendarBond, CalendarBondProps } from '../calendar/bond.svelte'; + +export type DatePickerBondProps = PopoverStateProps & + CalendarBondProps & { + format?: string; + placeholder?: string; + }; + +export type DatePickerBondElements = PopoverDomElements & { + trigger: HTMLInputElement; + root: HTMLElement; + content: HTMLElement; + clearButton: HTMLElement; +}; + +export class DatePickerBond< + Props extends DatePickerBondProps = DatePickerBondProps, + State extends DatePickerBondState = DatePickerBondState +> extends PopoverBond { + static override CONTEXT_KEY = '@atoms/context/date-picker'; + + #calendarBond?: CalendarBond; + + constructor(state: State) { + super(state); + } + + get calendar() { + return this.#calendarBond; + } + + setCalendar(calendar: CalendarBond) { + this.#calendarBond = calendar; + } + + override share(): this { + return DatePickerBond.set(this) as this; + } + + trigger() { + const isDisabled = this.state.props.disabled ?? false; + const placeholder = this.state.props.placeholder ?? 'Select a date'; + + return { + id: `date-picker-input-${this.id}`, + role: 'combobox', + 'aria-expanded': this.state.props.open ?? false, + 'aria-controls': `date-picker-calendar-${this.id}`, + 'aria-label': 'Date picker', + 'aria-disabled': isDisabled, + placeholder, + disabled: isDisabled, + readonly: true, + tabindex: isDisabled ? -1 : 0, + [createAttachmentKey()]: (node: HTMLInputElement) => { + this.elements.trigger = node; + } + }; + } + + content() { + return { + id: `date-picker-calendar-${this.id}`, + role: 'dialog', + 'aria-label': 'Choose date', + [createAttachmentKey()]: (node: HTMLElement) => { + this.elements.content = node; + } + }; + } + + clearButton(props: Record = {}) { + const hasValue = this.state.hasValue; + + return { + id: `date-picker-clear-${this.id}`, + type: 'button', + 'aria-label': 'Clear date', + tabindex: hasValue ? 0 : -1, + ...props, + onclick: (ev: Event) => { + ev.preventDefault(); + ev.stopPropagation(); + this.state.clear(); + }, + [createAttachmentKey()]: (node: HTMLElement) => { + this.elements.clearButton = node; + } + }; + } + + static override get(): DatePickerBond { + return getContext(this.CONTEXT_KEY); + } + + static override set(bond: DatePickerBond): DatePickerBond { + return setContext(this.CONTEXT_KEY, bond); + } +} + +export class DatePickerBondState extends PopoverState { + #isYearsPickerOpen = $state(false); + #isMonthsPickerOpen = $state(false); + + constructor(props: () => Props) { + super(props); + } + + get formattedValue() { + if (this.props.range) { + if (!this.props.start) return ''; + if (!this.props.end) return this.formatDate(this.props.start); + return `${this.formatDate(this.props.start)} - ${this.formatDate(this.props.end)}`; + } + + return this.props.value ? this.formatDate(this.props.value) : ''; + } + + get hasValue() { + if (this.props.range) { + return !!(this.props.start || this.props.end); + } + return !!this.props.value; + } + + get isYearsPickerOpen() { + return this.#isYearsPickerOpen; + } + + get isMonthsPickerOpen() { + return this.#isMonthsPickerOpen; + } + + selectDate(date: Date) { + if (this.props.range) { + if (!this.props.start) { + this.props.start = date; + } else if (!this.props.end) { + this.props.end = date; + this.close(); // Close after selecting range + } else { + this.props.start = date; + this.props.end = undefined; + } + } else { + this.props.value = date; + this.close(); // Close after selecting single date + } + } + + selectStart(date: Date) { + this.props.start = date; + } + + selectEnd(date: Date) { + this.props.end = date; + this.close(); + } + + clear() { + this.props.value = undefined; + this.props.start = undefined; + this.props.end = undefined; + } + + private formatDate(date: Date): string { + const format = this.props.format ?? 'MM/dd/yyyy'; + + // Basic formatting - can be enhanced with date-fns format later + if (format === 'MM/dd/yyyy') { + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const year = date.getFullYear(); + return `${month}/${day}/${year}`; + } + + if (format === 'dd/MM/yyyy') { + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const year = date.getFullYear(); + return `${day}/${month}/${year}`; + } + + if (format === 'yyyy-MM-dd') { + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const year = date.getFullYear(); + return `${year}-${month}-${day}`; + } + + // Default fallback + return date.toLocaleDateString(); + } + + openYearsPicker() { + this.#isYearsPickerOpen = true; + } + closeYearsPicker() { + this.#isYearsPickerOpen = false; + } + toggleYearsPicker() { + this.#isYearsPickerOpen = !this.#isYearsPickerOpen; + } + + openMonthsPicker() { + this.#isMonthsPickerOpen = true; + } + closeMonthsPicker() { + this.#isMonthsPickerOpen = false; + } + toggleMonthsPicker() { + this.#isMonthsPickerOpen = !this.#isMonthsPickerOpen; + } +} diff --git a/src/lib/components/date-picker/date-picker-calendar.svelte b/src/lib/components/date-picker/date-picker-calendar.svelte new file mode 100644 index 0000000..cfbccee --- /dev/null +++ b/src/lib/components/date-picker/date-picker-calendar.svelte @@ -0,0 +1,42 @@ + + + + {#snippet children({ calendar })} + {@render datePickerChildren?.({ + datePicker: datePickerBond + })} + {/snippet} + diff --git a/src/lib/components/date-picker/date-picker-header.svelte b/src/lib/components/date-picker/date-picker-header.svelte new file mode 100644 index 0000000..407b644 --- /dev/null +++ b/src/lib/components/date-picker/date-picker-header.svelte @@ -0,0 +1,105 @@ + + + + {#if children} + {@render children?.({ + datePicker: datePickerBond, + calendar: calendarBond, + monthName, + year, + onPrevious: handlePreviousMonth, + onNext: handleNextMonth + })} + {:else} + + + + + + + + + {/if} + diff --git a/src/lib/components/date-picker/date-picker-months.svelte b/src/lib/components/date-picker/date-picker-months.svelte new file mode 100644 index 0000000..2386f3a --- /dev/null +++ b/src/lib/components/date-picker/date-picker-months.svelte @@ -0,0 +1,150 @@ + + +{#if datePicker.state.isMonthsPickerOpen} + { + animate( + node, + { + opacity: [0, 1] + }, + { duration: 100 / 1000, ease: 'anticipate' } + ); + return { + duration: 100 + }; + }} + exit={(node) => { + animate( + node, + { + opacity: 0 + }, + { duration: 100 / 1000, ease: 'anticipate' } + ); + return { + duration: 100 + }; + }} + {preset} + {...restProps} + > + + {#if children} + {@render children?.({ + calendar, + popover, + currentYear, + currentMonth, + monthsGrid, + onMonthSelect: handleMonthSelect + })} + {:else} + + + + +
+ {#each monthsGrid as month, index} + {@const isSelected = index === currentMonth} + + {/each} +
+ {/if} +
+
+{/if} diff --git a/src/lib/components/date-picker/date-picker-root.svelte b/src/lib/components/date-picker/date-picker-root.svelte new file mode 100644 index 0000000..6b0f6fd --- /dev/null +++ b/src/lib/components/date-picker/date-picker-root.svelte @@ -0,0 +1,94 @@ + + + + {@render children?.({ datePicker: bond })} + diff --git a/src/lib/components/date-picker/date-picker-years.svelte b/src/lib/components/date-picker/date-picker-years.svelte new file mode 100644 index 0000000..124667a --- /dev/null +++ b/src/lib/components/date-picker/date-picker-years.svelte @@ -0,0 +1,214 @@ + + +{#if datePicker.state.isYearsPickerOpen} + { + animate( + node, + { + opacity: [0, 1] + }, + { duration: 100 / 1000, ease: 'anticipate' } + ); + return { + duration: 100 + }; + }} + exit={(node) => { + animate( + node, + { + opacity: 0 + }, + { duration: 100 / 1000, ease: 'anticipate' } + ); + return { + duration: 100 + }; + }} + onwheel={handleWheel} + {preset} + {...restProps} + > + {#if children} + {@render children?.({ + calendar, + popover, + currentYear, + yearsGrid, + onPrevious: handlePreviousYear, + onNext: handleNextYear, + onYearSelect: handleYearSelect + })} + {:else} + + + + + +
+ {#each yearsGrid as year} + {@const isSelected = year === pivote.getFullYear()} + {@const isCurrent = year === currentYear} + + {/each} +
+
+ {/if} +
+{/if} diff --git a/src/lib/components/date-picker/date-picker.stories.svelte b/src/lib/components/date-picker/date-picker.stories.svelte new file mode 100644 index 0000000..a3229b7 --- /dev/null +++ b/src/lib/components/date-picker/date-picker.stories.svelte @@ -0,0 +1,51 @@ + + + + + + {#snippet children({ args })} + + {#snippet children({})} +
+ + + {#if value} +
{value.toDateString()}
+ {:else} +
Open Date Picker
+ {/if} + + +
+ + + + + {#snippet children({ day })} + + {/snippet} + + + + + +
+
+ {/snippet} +
+ {/snippet} +
diff --git a/src/lib/components/date-picker/index.ts b/src/lib/components/date-picker/index.ts new file mode 100644 index 0000000..ab954d9 --- /dev/null +++ b/src/lib/components/date-picker/index.ts @@ -0,0 +1,3 @@ +export * as DatePicker from './atoms'; +export * from './bond.svelte'; +export * from './types'; \ No newline at end of file diff --git a/src/lib/components/date-picker/types.ts b/src/lib/components/date-picker/types.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/lib/components/date-picker/types.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/lib/components/index.ts b/src/lib/components/index.ts index 9bb5a78..c5a94d8 100644 --- a/src/lib/components/index.ts +++ b/src/lib/components/index.ts @@ -35,3 +35,6 @@ export * from './stack'; export * from './element'; export * from './atom'; export * from './container'; +export * from './calendar'; +export * from './date-picker'; +export * from './qr-code'; \ No newline at end of file diff --git a/src/lib/components/popover/popover-content.svelte b/src/lib/components/popover/popover-content.svelte index 9a1cb78..33902a2 100644 --- a/src/lib/components/popover/popover-content.svelte +++ b/src/lib/components/popover/popover-content.svelte @@ -59,12 +59,12 @@ const xOffset = $derived(dx * offset); const yOffset = $derived(dy * offset); + const openAsNumber = $derived(+isOpen); + const deltaArrow = $derived(position?.middlewareData?.arrow ? 1 : 0); + let isInitialized = false; function _containerInitial(this: typeof bond.state, node: Element) { - const openAsNumber = +this.isOpen; - - const deltaArrow = position?.middlewareData?.arrow ? 1 : 0; const arrowClientWidth = bond?.elements.arrow?.clientWidth ?? 0; const arrowClientHeight = bond?.elements.arrow?.clientHeight ?? 0; @@ -81,8 +81,6 @@ return; } - const openAsNumber = +this.isOpen; - const deltaArrow = position?.middlewareData?.arrow ? 1 : 0; const arrowClientWidth = bond?.elements.arrow?.clientWidth ?? 0; const arrowClientHeight = bond?.elements.arrow?.clientHeight ?? 0; @@ -93,17 +91,58 @@ node.style.transform = `translate3d(${_x}px, ${_y}px, 1px)`; } + let isOpened = false; + function _animate(this: typeof bond.state, node: Element) { const isOpen = this.isOpen; + const arrowClientWidth = bond?.elements.arrow?.clientWidth ?? 0; + const arrowClientHeight = bond?.elements.arrow?.clientHeight ?? 0; + + const _x = openAsNumber * dx; + const _y = openAsNumber * dy; + + const getTransformOrigin = () => { + switch (placement) { + case 'top': + case 'top-start': + case 'top-end': + return 'bottom'; + case 'bottom': + case 'bottom-start': + case 'bottom-end': + return 'top'; + case 'left': + case 'left-start': + case 'left-end': + return 'right'; + case 'right': + case 'right-start': + case 'right-end': + return 'left'; + default: + return 'center'; + } + }; + + const transformOrigin = getTransformOrigin(); + + const from = isOpened ? 1 : 0.95; + motion( node, { - opacity: +isOpen, - y: (isOpen ? 0 : -1) * dy * 8 + opacity: openAsNumber, + y: _y + dy * (!isOpen ? -1 : 0) * (arrowClientHeight + yOffset), + x: _x + dx * (!isOpen ? -1 : 0) * (arrowClientWidth + xOffset), + scaleY: dy ? (isOpen ? [from, 1] : [1, 0.8]) : undefined, + scaleX: dx ? (isOpen ? [from, 1] : [1, 0.8]) : undefined, + transformOrigin }, { duration: DURATION.fast / 1000 } ); + + isOpened = isOpen; } diff --git a/src/lib/components/popover/popover-root.svelte b/src/lib/components/popover/popover-root.svelte index 0c856c8..6cea2a4 100644 --- a/src/lib/components/popover/popover-root.svelte +++ b/src/lib/components/popover/popover-root.svelte @@ -15,23 +15,22 @@ children = undefined }: PopoverRootProps = $props(); - const bondProps = defineState( - [ - defineProperty( - 'open', - () => open, - (v) => { - open = v; - } - ), - defineProperty('disabled', () => disabled), - defineProperty('placement', () => placement), - defineProperty('offset', () => offset), - defineProperty('placements', () => placements ?? []), - defineProperty('portal', () => portal) - ], - () => ({ extend }) - ); + const bondProps = defineState([ + defineProperty( + 'open', + () => open, + (v) => { + open = v; + } + ), + defineProperty('disabled', () => disabled), + defineProperty('placement', () => placement), + defineProperty('offset', () => offset), + defineProperty('placements', () => placements ?? []), + defineProperty('portal', () => portal), + defineProperty('extend', () => extend) + ]); + const bond = factory(bondProps).share(); function _factory(props: typeof bondProps) { diff --git a/src/lib/components/popover/popover.stories.svelte b/src/lib/components/popover/popover.stories.svelte index 7844fbb..848888e 100644 --- a/src/lib/components/popover/popover.stories.svelte +++ b/src/lib/components/popover/popover.stories.svelte @@ -3,7 +3,6 @@ import { Popover as Popover_ } from '.'; import Root from '$svelte-atoms/core/components/root/root.svelte'; import { clickoutPopover } from './attachments.svelte'; - import { animate } from 'motion'; import { Button } from '../button'; // More on how to set up stories at: https://storybook.js.org/docs/writing-stories @@ -42,20 +41,6 @@ atom.state.close(); })} class="bg-card" - animate={function (this, node) { - const isOpen = this.isOpen; - - const m = animate( - node, - { - y: (isOpen ? 0 : -1) * 8, - opacity: +isOpen - }, - { - duration: 0.1 - } - ); - }} >
Hello World !
diff --git a/src/lib/components/qr-code/index.ts b/src/lib/components/qr-code/index.ts new file mode 100644 index 0000000..f91b062 --- /dev/null +++ b/src/lib/components/qr-code/index.ts @@ -0,0 +1 @@ +export { default as QRCode } from './qr-code.svelte'; diff --git a/src/lib/components/qr-code/qr-code.stories.svelte b/src/lib/components/qr-code/qr-code.stories.svelte new file mode 100644 index 0000000..9031d28 --- /dev/null +++ b/src/lib/components/qr-code/qr-code.stories.svelte @@ -0,0 +1,24 @@ + + + + + + {#snippet children(args)} + + {#snippet children({})} +
+ +
+ {/snippet} +
+ {/snippet} +
diff --git a/src/lib/components/qr-code/qr-code.svelte b/src/lib/components/qr-code/qr-code.svelte new file mode 100644 index 0000000..e3d7cd8 --- /dev/null +++ b/src/lib/components/qr-code/qr-code.svelte @@ -0,0 +1,25 @@ + + + + {#if code} + {@html code} + {/if} + diff --git a/src/lib/context/preset.svelte.ts b/src/lib/context/preset.svelte.ts index 42846ae..1c8e878 100644 --- a/src/lib/context/preset.svelte.ts +++ b/src/lib/context/preset.svelte.ts @@ -122,7 +122,17 @@ export type PresetModuleName = | 'checkbox.indeterminate' | 'radio' | 'radio.group' - | 'container'; + | 'container' + | 'calendar' + | 'calendar.day' + | 'calendar.header' + | 'calendar.weekday' + | 'calendar.body' + | 'datepicker.trigger' + | 'datepicker.calendar' + | 'datepicker.years' + | 'datepicker.months' + | 'datepicker.header'; export type PresetEntryRecord = { [key: string]: unknown; diff --git a/src/lib/shared/bond.svelte.ts b/src/lib/shared/bond.svelte.ts index 8d7c993..53fdb7d 100644 --- a/src/lib/shared/bond.svelte.ts +++ b/src/lib/shared/bond.svelte.ts @@ -1,6 +1,6 @@ import { nanoid } from 'nanoid'; -export type BondStateProps = Record & { id?: string }; +export type BondStateProps = { id?: string }; export type BondElements = Record; export abstract class Bond< diff --git a/src/lib/utils/state.ts b/src/lib/utils/state.ts index 08c3797..19d2732 100644 --- a/src/lib/utils/state.ts +++ b/src/lib/utils/state.ts @@ -13,7 +13,7 @@ export function defineState( return outcome as T; } -export function defineProperty( +export function defineProperty, R>( property: keyof T | (string & {}), get: () => R, set?: (value: R) => void @@ -25,7 +25,8 @@ export function defineProperty( return Object.defineProperty(base, property, { get: get, - set: set + set: set, + enumerable: true }); }; } diff --git a/src/stories/Theme.svelte b/src/stories/Theme.svelte index 35897e0..1e9633b 100644 --- a/src/stories/Theme.svelte +++ b/src/stories/Theme.svelte @@ -29,26 +29,31 @@ class: 'overflow-hidden' }), button: () => ({ + class: '', variants: { variant: { primary: { - class: 'bg-primary text-primary-foreground hover:bg-primary/80' + class: 'bg-primary text-primary-foreground hover:bg-primary/80 active:bg-primary/90' }, secondary: { - class: 'bg-secondary text-secondary-foreground hover:bg-secondary/80' + class: + 'bg-secondary text-secondary-foreground hover:bg-secondary/80 active:bg-secondary/90' }, destructive: { - class: 'bg-destructive text-destructive-foreground hover:bg-destructive/80' + class: + 'bg-destructive text-destructive-foreground hover:bg-destructive/80 active:bg-destructive/90' }, outline: { class: 'bg-transparent hover:bg-foreground/5 active:bg-foreground/10 border border-border text-foreground' }, ghost: { - class: 'hover:bg-accent hover:text-accent-foreground' + class: + 'bg-transparent text-foreground hover:bg-foreground/5 active:bg-foreground/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2' } } }, + compounds: [], defaults: { variant: 'primary' } @@ -73,6 +78,52 @@ }), 'popover.content': () => ({ class: '' + }), + + alert: () => ({ + class: 'relative gap-1 rounded-md border p-4 transition-all duration-200', + variants: { + variant: { + info: { + class: + 'bg-blue-50 border-blue-200 text-blue-900 dark:bg-blue-950/50 dark:border-blue-800 dark:text-blue-100' + }, + success: { + class: + 'bg-green-50 border-green-200 text-green-900 dark:bg-green-950/50 dark:border-green-800 dark:text-green-100' + }, + warning: { + class: + 'bg-yellow-50 border-yellow-200 text-yellow-900 dark:bg-yellow-950/50 dark:border-yellow-800 dark:text-yellow-100' + }, + error: { + class: + 'bg-red-50 border-red-200 text-red-900 dark:bg-red-950/50 dark:border-red-800 dark:text-red-100' + } + } + }, + defaults: { + variant: 'info' + } + }), + 'alert.icon': () => ({ + class: 'inline-flex aspect-square size-4 shrink-0 items-center justify-center' + }), + 'alert.content': () => ({ + class: 'flex-1 space-y-1' + }), + 'alert.title': () => ({ + class: 'text-md font-semibold leading-tight' + }), + 'alert.description': () => ({ + class: 'text-sm leading-relaxed opacity-90' + }), + 'alert.actions': () => ({ + class: 'mt-3 flex items-center gap-2' + }), + 'alert.close-button': () => ({ + class: + 'rounded-md p-0.5 size-6 opacity-70 transition-all hover:opacity-100 hover:bg-black/10 dark:hover:bg-white/10 focus:outline-none focus:ring-2 focus:ring-offset-1' }) }; diff --git a/tsconfig.json b/tsconfig.json index dd288d3..7e0f5bc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,15 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, "module": "esnext", - "moduleResolution": "bundler" + "moduleResolution": "bundler", + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "verbatimModuleSyntax": true } } diff --git a/vite.config.ts b/vite.config.ts index b15c0ae..c9c90c5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,8 +5,18 @@ import { defineConfig } from 'vite'; export default defineConfig({ plugins: [tailwindcss(), sveltekit(), devtoolsJson()], + build: { + target: 'esnext', + minify: 'esbuild', + sourcemap: true, + reportCompressedSize: false + }, + optimizeDeps: { + include: ['clsx', 'tailwind-merge', 'es-toolkit', 'date-fns'] + }, test: { expect: { requireAssertions: true }, + globals: true, projects: [ { extends: './vite.config.ts', @@ -16,7 +26,8 @@ export default defineConfig({ browser: { enabled: true, provider: 'playwright', - instances: [{ browser: 'chromium' }] + instances: [{ browser: 'chromium' }], + headless: true }, include: ['src/**/*.svelte.{test,spec}.{js,ts}'], exclude: ['src/lib/server/**'],