diff --git a/ADAPTERS.md b/ADAPTERS.md new file mode 100644 index 0000000..bbac105 --- /dev/null +++ b/ADAPTERS.md @@ -0,0 +1,423 @@ +# Framework Adapters + +Gracket provides first-class adapters for all major frontend frameworks, each following the latest best practices and patterns for their respective ecosystems. + +## 📦 Installation + +All adapters are included in the main `gracket` package. Simply install once: + +```bash +npm install gracket +``` + +Then import the adapter for your framework: + +```typescript +// React +import { GracketReact } from 'gracket/react'; + +// Vue +import { GracketVue } from 'gracket/vue'; + +// Angular +import { GracketComponent } from 'gracket/angular'; + +// SolidJS +import { GracketSolid } from 'gracket/solid'; + +// Svelte +import { gracket } from 'gracket/svelte'; + +// Web Components +import { GracketElement } from 'gracket/webcomponent'; +``` + +--- + +## ⚛️ React Adapter + +Modern React 18+ adapter with hooks, memo, and performance optimizations. + +### Component Usage + +```tsx +import { GracketReact } from 'gracket/react'; +import 'gracket/style.css'; + +function TournamentBracket() { + const [data, setData] = useState(tournamentData); + + return ( + console.log('Initialized!', instance)} + onError={(error) => console.error(error)} + /> + ); +} +``` + +### Hook Usage + +```tsx +import { useGracket } from 'gracket/react'; + +function TournamentBracket() { + const { containerRef, gracket, error, updateScore, advanceRound } = useGracket( + tournamentData, + { cornerRadius: 20 } + ); + + return
; +} +``` + +**Features:** +- ✅ React 18+ memoization and performance optimization +- ✅ Stable refs and callbacks +- ✅ Error boundaries compatible +- ✅ TypeScript support +- ✅ SSR compatible + +--- + +## 🔷 Vue Adapter + +Vue 3.5+ adapter with Composition API, composables, and reactivity. + +### Component Usage + +```vue + + + +``` + +### Composable Usage + +```vue + + + +``` + +**Features:** +- ✅ Vue 3.5+ with latest Composition API patterns +- ✅ Deep reactivity with `shallowRef` optimization +- ✅ Exposed component API via `expose()` +- ✅ TypeScript support +- ✅ SSR compatible + +--- + +## 🅰️ Angular Adapter + +Angular 18+ component with signals, standalone components, and modern patterns. + +### Component Usage + +```typescript +import { Component } from '@angular/core'; +import { GracketComponent } from 'gracket/angular'; + +@Component({ + selector: 'app-tournament', + standalone: true, + imports: [GracketComponent], + template: ` + + ` +}) +export class TournamentComponent { + tournamentData = [...]; + options = { cornerRadius: 20 }; + + onInit(instance: Gracket) { + console.log('Initialized!', instance); + } +} +``` + +### Service Usage + +```typescript +import { Component, inject } from '@angular/core'; +import { GracketService } from 'gracket/angular'; + +@Component({ + selector: 'app-tournament', + template: `
` +}) +export class TournamentComponent { + private gracketService = inject(GracketService); + @ViewChild('container') container!: ElementRef; + + ngAfterViewInit() { + this.gracketService.create( + 'my-bracket', + this.container.nativeElement, + tournamentData + ); + } +} +``` + +**Features:** +- ✅ Angular 18+ with signals and modern patterns +- ✅ Standalone component (no module required) +- ✅ `inject()` function support +- ✅ Change detection optimized +- ✅ TypeScript support + +--- + +## 🔷 SolidJS Adapter + +SolidJS adapter with fine-grained reactivity and performance. + +### Component Usage + +```tsx +import { createSignal } from 'solid-js'; +import { GracketSolid } from 'gracket/solid'; +import 'gracket/style.css'; + +function TournamentBracket() { + const [data, setData] = createSignal(tournamentData); + + return ( + console.log('Initialized!', instance)} + /> + ); +} +``` + +### Hook Usage + +```tsx +import { useGracket } from 'gracket/solid'; + +function TournamentBracket() { + const { containerRef, instance, error, updateScore } = useGracket( + tournamentData, + { cornerRadius: 20 } + ); + + return
; +} +``` + +**Features:** +- ✅ Fine-grained reactivity +- ✅ Minimal re-renders +- ✅ TypeScript support +- ✅ SSR compatible + +--- + +## 🔥 Svelte Adapter + +Svelte 5+ adapter with runes, actions, and modern patterns. + +### Svelte 5 Action (Recommended) + +```svelte + + +
+``` + +### Svelte 5 Composable + +```svelte + + +
+``` + +### Svelte 4 Component (Legacy) + +```svelte + + + +``` + +**Features:** +- ✅ Svelte 5 runes support (`$state`, `$effect`) +- ✅ Action-based API (most idiomatic) +- ✅ Backward compatible with Svelte 4 +- ✅ TypeScript support +- ✅ SSR compatible + +--- + +## 🌐 Web Components Adapter + +Standards-compliant custom element with Shadow DOM. + +### Usage + +```html + + + +``` + +### With Framework + +Works with any framework or vanilla JS: + +```typescript +import { GracketElement } from 'gracket/webcomponent'; + +const bracket = new GracketElement(); +bracket.data = tournamentData; +bracket.options = { cornerRadius: 20 }; +document.body.appendChild(bracket); +``` + +**Features:** +- ✅ Standards-compliant custom element +- ✅ Shadow DOM encapsulation +- ✅ Framework-agnostic +- ✅ TypeScript support +- ✅ Custom events for lifecycle hooks + +--- + +## 🎨 Styling + +All adapters use the same CSS. Import once: + +```typescript +import 'gracket/style.css'; +``` + +Or in HTML: + +```html + +``` + +--- + +## 📖 Common API + +All adapters expose these common methods: + +### Component Props/Attributes + +- `data` - Tournament data (required) +- `options` - Gracket configuration options +- `className`/`class` - CSS class name +- `style` - Inline styles + +### Instance Methods + +- `updateScore(roundIndex, gameIndex, teamIndex, score)` - Update a team's score +- `advanceRound(fromRound?)` - Advance winners to next round +- `getInstance()` - Get the underlying Gracket instance +- `destroy()` - Clean up and destroy the instance + +### Events/Callbacks + +- `onInit`/`init` - Fired when initialized +- `onError`/`error` - Fired on error +- `onUpdate`/`update` - Fired when data updates + +--- + +## 🤝 TypeScript Support + +All adapters are fully typed with TypeScript: + +```typescript +import type { TournamentData, GracketOptions } from 'gracket'; +import type { GracketReactProps } from 'gracket/react'; +``` + +--- + +## 📚 Examples + +Check out the `demo/` directory for complete examples with each framework. + +--- + +## 🐛 Issues & Contributions + +Found a bug or want to contribute? Visit our [GitHub repository](https://github.com/Zettersten/jquery.gracket.js). diff --git a/package.json b/package.json index 1fe3849..a05298d 100644 --- a/package.json +++ b/package.json @@ -12,12 +12,28 @@ "types": "./dist/index.d.ts" }, "./react": { - "import": "./dist/react.js", - "types": "./dist/react.d.ts" + "import": "./dist/adapters/react.js", + "types": "./dist/adapters/react.d.ts" }, "./vue": { - "import": "./dist/vue.js", - "types": "./dist/vue.d.ts" + "import": "./dist/adapters/vue.js", + "types": "./dist/adapters/vue.d.ts" + }, + "./angular": { + "import": "./dist/adapters/angular.js", + "types": "./dist/adapters/angular.d.ts" + }, + "./solid": { + "import": "./dist/adapters/solid.js", + "types": "./dist/adapters/solid.d.ts" + }, + "./svelte": { + "import": "./dist/adapters/svelte.js", + "types": "./dist/adapters/svelte.d.ts" + }, + "./webcomponent": { + "import": "./dist/adapters/webcomponent.js", + "types": "./dist/adapters/webcomponent.d.ts" }, "./style.css": "./dist/style.css" }, @@ -50,6 +66,11 @@ "sports", "react", "vue", + "angular", + "solid", + "solidjs", + "svelte", + "web-components", "typescript" ], "author": "Erik Zettersten", @@ -83,13 +104,25 @@ "vue": "^3.5.12" }, "peerDependencies": { - "react": ">=16.8.0", + "@angular/core": ">=18.0.0", + "react": ">=18.0.0", + "solid-js": ">=1.8.0", + "svelte": ">=4.0.0 || >=5.0.0", "vue": ">=3.0.0" }, "peerDependenciesMeta": { + "@angular/core": { + "optional": true + }, "react": { "optional": true }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, "vue": { "optional": true } diff --git a/src/adapters/angular.ts b/src/adapters/angular.ts new file mode 100644 index 0000000..df431e7 --- /dev/null +++ b/src/adapters/angular.ts @@ -0,0 +1,228 @@ +import { + Component, + Input, + Output, + EventEmitter, + OnInit, + OnDestroy, + OnChanges, + SimpleChanges, + ElementRef, + ViewChild, + ChangeDetectionStrategy, + inject, + effect, + signal, +} from '@angular/core'; +import { Gracket } from '../core/Gracket'; +import type { GracketOptions, TournamentData } from '../types'; + +/** + * Angular 18+ Component wrapper for Gracket + * Uses modern Angular features: signals, inject, standalone components + */ +@Component({ + selector: 'gracket', + standalone: true, + template: ` + @if (error()) { +
+ Error initializing Gracket: {{ error()?.message }} +
+ } @else { +
+ } + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class GracketComponent implements OnInit, OnDestroy, OnChanges { + @ViewChild('container', { static: false }) containerRef?: ElementRef; + + @Input({ required: true }) data!: TournamentData; + @Input() options?: Omit; + @Input() className?: string; + @Input() style?: Record; + + @Output() init = new EventEmitter(); + @Output() errorOccurred = new EventEmitter(); + @Output() dataUpdated = new EventEmitter(); + + // Using signals for reactive state - Angular 18+ best practice + protected error = signal(null); + private gracketInstance = signal(null); + private elementRef = inject(ElementRef); + + ngOnInit(): void { + // Initialize will happen after view init when container is available + } + + ngAfterViewInit(): void { + this.initializeGracket(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['data'] && !changes['data'].firstChange) { + this.updateData(); + } + + if (changes['options'] && !changes['options'].firstChange) { + this.reinitializeGracket(); + } + } + + ngOnDestroy(): void { + this.destroy(); + } + + private initializeGracket(): void { + const container = this.containerRef?.nativeElement; + if (!container || !this.data?.length) return; + + try { + const instance = new Gracket(container, { + ...this.options, + src: this.data, + }); + + this.gracketInstance.set(instance); + this.error.set(null); + this.init.emit(instance); + } catch (err) { + const error = err as Error; + this.error.set(error); + this.errorOccurred.emit(error); + console.error('Gracket initialization error:', err); + } + } + + private reinitializeGracket(): void { + this.destroy(); + this.initializeGracket(); + } + + private updateData(): void { + const instance = this.gracketInstance(); + if (instance && this.data?.length) { + try { + instance.update(this.data); + this.error.set(null); + this.dataUpdated.emit(this.data); + } catch (err) { + const error = err as Error; + this.error.set(error); + this.errorOccurred.emit(error); + } + } + } + + /** Update a team's score */ + public updateScore( + roundIndex: number, + gameIndex: number, + teamIndex: number, + score: number + ): void { + try { + this.gracketInstance()?.updateScore(roundIndex, gameIndex, teamIndex, score); + this.error.set(null); + } catch (err) { + this.error.set(err as Error); + } + } + + /** Advance to next round */ + public advanceRound(fromRound?: number): TournamentData | undefined { + try { + const result = this.gracketInstance()?.advanceRound(fromRound); + this.error.set(null); + return result; + } catch (err) { + this.error.set(err as Error); + return undefined; + } + } + + /** Get the Gracket instance */ + public getInstance(): Gracket | null { + return this.gracketInstance(); + } + + /** Destroy the instance */ + public destroy(): void { + const instance = this.gracketInstance(); + if (instance) { + instance.destroy(); + this.gracketInstance.set(null); + } + } +} + +/** + * Angular Service for programmatic Gracket control + * Can be injected into components + */ +export class GracketService { + private instances = new Map(); + + /** Create a new Gracket instance */ + create( + id: string, + container: HTMLElement, + data: TournamentData, + options?: GracketOptions + ): Gracket { + // Destroy existing instance if any + this.destroy(id); + + const instance = new Gracket(container, { + ...options, + src: data, + }); + + this.instances.set(id, instance); + return instance; + } + + /** Get an existing instance */ + get(id: string): Gracket | undefined { + return this.instances.get(id); + } + + /** Update data for an instance */ + update(id: string, data: TournamentData): void { + this.instances.get(id)?.update(data); + } + + /** Update score */ + updateScore( + id: string, + roundIndex: number, + gameIndex: number, + teamIndex: number, + score: number + ): void { + this.instances.get(id)?.updateScore(roundIndex, gameIndex, teamIndex, score); + } + + /** Advance round */ + advanceRound(id: string, fromRound?: number): TournamentData | undefined { + return this.instances.get(id)?.advanceRound(fromRound); + } + + /** Destroy an instance */ + destroy(id: string): void { + const instance = this.instances.get(id); + if (instance) { + instance.destroy(); + this.instances.delete(id); + } + } + + /** Destroy all instances */ + destroyAll(): void { + this.instances.forEach((instance) => instance.destroy()); + this.instances.clear(); + } +} + +export default GracketComponent; diff --git a/src/adapters/index.ts b/src/adapters/index.ts new file mode 100644 index 0000000..9468335 --- /dev/null +++ b/src/adapters/index.ts @@ -0,0 +1,20 @@ +/** + * Framework adapters for Gracket + * + * Modern, framework-specific wrappers for the Gracket tournament bracket library. + * Each adapter follows the latest best practices for its respective framework. + * + * @packageDocumentation + */ + +// Re-export core library +export { Gracket } from '../core/Gracket'; +export * from '../types'; + +// Note: Framework adapters should be imported directly from their submodules: +// - import { GracketReact } from 'gracket/react' +// - import { GracketVue } from 'gracket/vue' +// - import { GracketComponent } from 'gracket/angular' +// - import { GracketSolid } from 'gracket/solid' +// - import { gracket, createGracket } from 'gracket/svelte' +// - import { GracketElement } from 'gracket/webcomponent' diff --git a/src/adapters/react.tsx b/src/adapters/react.tsx index f492bb9..c39d50f 100644 --- a/src/adapters/react.tsx +++ b/src/adapters/react.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, useCallback } from 'react'; +import { useEffect, useRef, useState, useCallback, useMemo, memo } from 'react'; import { Gracket } from '../core/Gracket'; import type { GracketOptions, TournamentData } from '../types'; @@ -11,64 +11,87 @@ export interface GracketReactProps extends Omit { style?: React.CSSProperties; /** Callback when bracket is initialized */ onInit?: (gracket: Gracket) => void; + /** Callback when an error occurs */ + onError?: (error: Error) => void; } /** - * React component wrapper for Gracket + * React component wrapper for Gracket - Modern React 18+ best practices */ -export const GracketReact: React.FC = ({ +const GracketReactComponent: React.FC = ({ data, className, style, onInit, + onError, ...options }) => { const containerRef = useRef(null); const gracketRef = useRef(null); const [error, setError] = useState(null); - + + // Stabilize options object to prevent unnecessary re-renders + const stableOptions = useMemo(() => options, [ + options.gracketClass, + options.gameClass, + options.roundClass, + options.roundLabelClass, + options.teamClass, + options.winnerClass, + options.spacerClass, + options.currentClass, + options.seedClass, + options.cornerRadius, + options.canvasId, + options.canvasClass, + options.canvasLineColor, + options.canvasLineCap, + options.canvasLineWidth, + options.canvasLineGap, + options.byeLabel, + options.byeClass, + options.showByeGames, + // Callbacks are intentionally excluded from deps + ]); + + // Initialize Gracket instance useEffect(() => { if (!containerRef.current || !data?.length) return; try { - // Create new Gracket instance gracketRef.current = new Gracket(containerRef.current, { - ...options, + ...stableOptions, src: data, }); onInit?.(gracketRef.current); + setError(null); } catch (err) { - setError(err as Error); + const error = err as Error; + setError(error); + onError?.(error); console.error('Gracket initialization error:', err); } - // Cleanup on unmount return () => { gracketRef.current?.destroy(); gracketRef.current = null; }; - }, []); // Only initialize once + }, [stableOptions, onInit, onError]); - // Update data when it changes + // Update data reactively useEffect(() => { if (gracketRef.current && data?.length) { - gracketRef.current.update(data); + try { + gracketRef.current.update(data); + setError(null); + } catch (err) { + const error = err as Error; + setError(error); + onError?.(error); + } } - }, [data]); - - // Update options when they change - useEffect(() => { - if (gracketRef.current && data?.length) { - // Recreate with new options - gracketRef.current.destroy(); - gracketRef.current = new Gracket(containerRef.current!, { - ...options, - src: data, - }); - onInit?.(gracketRef.current); - } - }, [options, onInit]); + }, [data, onError]); if (error) { return ( @@ -81,49 +104,107 @@ export const GracketReact: React.FC = ({ return
; }; +// Memoized component export for performance +export const GracketReact = memo(GracketReactComponent); + /** - * Hook for programmatic Gracket control + * Hook for programmatic Gracket control - React 18+ optimized */ export const useGracket = ( data: TournamentData, options?: GracketOptions -): { - containerRef: React.RefObject; - gracket: Gracket | null; - update: (newData: TournamentData) => void; - destroy: () => void; -} => { +) => { const containerRef = useRef(null); const gracketRef = useRef(null); + const [instance, setInstance] = useState(null); + const [error, setError] = useState(null); + + // Stabilize options + const stableOptions = useMemo(() => options, [JSON.stringify(options)]); + // Initialize on mount useEffect(() => { if (!containerRef.current || !data?.length) return; - gracketRef.current = new Gracket(containerRef.current, { - ...options, - src: data, - }); + try { + const gracket = new Gracket(containerRef.current, { + ...stableOptions, + src: data, + }); + + gracketRef.current = gracket; + setInstance(gracket); + setError(null); + } catch (err) { + setError(err as Error); + } return () => { gracketRef.current?.destroy(); gracketRef.current = null; + setInstance(null); }; - }, []); + }, [stableOptions]); + // Update data + useEffect(() => { + if (gracketRef.current && data?.length) { + try { + gracketRef.current.update(data); + setError(null); + } catch (err) { + setError(err as Error); + } + } + }, [data]); + + // Stable API methods const update = useCallback((newData: TournamentData) => { - gracketRef.current?.update(newData); + try { + gracketRef.current?.update(newData); + setError(null); + } catch (err) { + setError(err as Error); + } }, []); const destroy = useCallback(() => { gracketRef.current?.destroy(); gracketRef.current = null; + setInstance(null); + }, []); + + const updateScore = useCallback(( + roundIndex: number, + gameIndex: number, + teamIndex: number, + score: number + ) => { + try { + gracketRef.current?.updateScore(roundIndex, gameIndex, teamIndex, score); + setError(null); + } catch (err) { + setError(err as Error); + } + }, []); + + const advanceRound = useCallback((fromRound?: number) => { + try { + return gracketRef.current?.advanceRound(fromRound); + } catch (err) { + setError(err as Error); + return undefined; + } }, []); return { containerRef, - gracket: gracketRef.current, + gracket: instance, + error, update, destroy, + updateScore, + advanceRound, }; }; diff --git a/src/adapters/solid.tsx b/src/adapters/solid.tsx new file mode 100644 index 0000000..747afc1 --- /dev/null +++ b/src/adapters/solid.tsx @@ -0,0 +1,188 @@ +import { + createSignal, + createEffect, + onMount, + onCleanup, + mergeProps, + type Component, + type JSX, +} from 'solid-js'; +import { Gracket } from '../core/Gracket'; +import type { GracketOptions, TournamentData } from '../types'; + +export interface GracketSolidProps extends Omit { + /** Tournament data */ + data: TournamentData; + /** Additional CSS class for container */ + class?: string; + /** Inline styles for container */ + style?: JSX.CSSProperties; + /** Callback when bracket is initialized */ + onInit?: (gracket: Gracket) => void; + /** Callback when an error occurs */ + onError?: (error: Error) => void; +} + +/** + * SolidJS component wrapper for Gracket + * Uses modern SolidJS best practices with fine-grained reactivity + */ +export const GracketSolid: Component = (props) => { + const merged = mergeProps({ class: '', style: {} }, props); + + let containerRef: HTMLDivElement | undefined; + let gracketInstance: Gracket | null = null; + + const [error, setError] = createSignal(null); + + // Initialize Gracket on mount + onMount(() => { + if (!containerRef || !merged.data?.length) return; + + try { + const { data, onInit, onError, class: _, style: __, ...options } = merged; + + gracketInstance = new Gracket(containerRef, { + ...options, + src: data, + }); + + setError(null); + merged.onInit?.(gracketInstance); + } catch (err) { + const error = err as Error; + setError(error); + merged.onError?.(error); + console.error('Gracket initialization error:', err); + } + }); + + // React to data changes + createEffect(() => { + if (gracketInstance && merged.data?.length) { + try { + gracketInstance.update(merged.data); + setError(null); + } catch (err) { + const error = err as Error; + setError(error); + merged.onError?.(error); + } + } + }); + + // Cleanup on unmount + onCleanup(() => { + gracketInstance?.destroy(); + gracketInstance = null; + }); + + return ( + <> + {error() ? ( +
+ Error initializing Gracket: {error()!.message} +
+ ) : ( +
+ )} + + ); +}; + +/** + * SolidJS hook for programmatic Gracket control + */ +export const useGracket = ( + data: TournamentData, + options?: GracketOptions +) => { + let containerRef: HTMLDivElement | undefined; + let gracketInstance: Gracket | null = null; + + const [instance, setInstance] = createSignal(null); + const [error, setError] = createSignal(null); + + onMount(() => { + if (!containerRef || !data?.length) return; + + try { + gracketInstance = new Gracket(containerRef, { + ...options, + src: data, + }); + + setInstance(gracketInstance); + setError(null); + } catch (err) { + setError(err as Error); + } + }); + + createEffect(() => { + if (gracketInstance && data?.length) { + try { + gracketInstance.update(data); + setError(null); + } catch (err) { + setError(err as Error); + } + } + }); + + onCleanup(() => { + gracketInstance?.destroy(); + gracketInstance = null; + setInstance(null); + }); + + const update = (newData: TournamentData) => { + try { + gracketInstance?.update(newData); + setError(null); + } catch (err) { + setError(err as Error); + } + }; + + const updateScore = ( + roundIndex: number, + gameIndex: number, + teamIndex: number, + score: number + ) => { + try { + gracketInstance?.updateScore(roundIndex, gameIndex, teamIndex, score); + setError(null); + } catch (err) { + setError(err as Error); + } + }; + + const advanceRound = (fromRound?: number) => { + try { + return gracketInstance?.advanceRound(fromRound); + } catch (err) { + setError(err as Error); + return undefined; + } + }; + + const destroy = () => { + gracketInstance?.destroy(); + gracketInstance = null; + setInstance(null); + }; + + return { + containerRef: (el: HTMLDivElement) => (containerRef = el), + instance, + error, + update, + updateScore, + advanceRound, + destroy, + }; +}; + +export default GracketSolid; diff --git a/src/adapters/svelte.svelte b/src/adapters/svelte.svelte new file mode 100644 index 0000000..f21463d --- /dev/null +++ b/src/adapters/svelte.svelte @@ -0,0 +1,102 @@ + + +{#if error} +
+ Error initializing Gracket: {error.message} +
+{:else} +
+{/if} diff --git a/src/adapters/svelte.ts b/src/adapters/svelte.ts new file mode 100644 index 0000000..2792025 --- /dev/null +++ b/src/adapters/svelte.ts @@ -0,0 +1,190 @@ +import { Gracket } from '../core/Gracket'; +import type { GracketOptions, TournamentData } from '../types'; + +/** + * Svelte 5+ action for Gracket + * Modern approach using Svelte actions with full reactivity + * + * @example + * ```svelte + * + * + *
+ * ``` + */ +export function gracket( + node: HTMLElement, + params: { + data: TournamentData; + options?: Omit; + onInit?: (instance: Gracket) => void; + onError?: (error: Error) => void; + } +) { + let instance: Gracket | null = null; + + const init = () => { + if (!params.data?.length) return; + + try { + instance = new Gracket(node, { + ...params.options, + src: params.data, + }); + + params.onInit?.(instance); + } catch (err) { + const error = err as Error; + params.onError?.(error); + console.error('Gracket initialization error:', err); + } + }; + + const update = (newParams: typeof params) => { + if (!instance || !newParams.data?.length) return; + + try { + instance.update(newParams.data); + } catch (err) { + params.onError?.(err as Error); + } + }; + + const destroy = () => { + instance?.destroy(); + instance = null; + }; + + // Initialize + init(); + + return { + update, + destroy, + }; +} + +/** + * Svelte 5 runes-based composable + * For use with Svelte 5's new runes system + * + * @example + * ```svelte + * + * + *
+ * ``` + */ +export function createGracket( + data: TournamentData, + options?: GracketOptions +) { + let containerRef: HTMLElement | null = $state(null); + let instance: Gracket | null = $state(null); + let error: Error | null = $state(null); + + // Initialize when container is available + $effect(() => { + if (!containerRef || !data?.length) return; + + try { + const gracket = new Gracket(containerRef, { + ...options, + src: data, + }); + + instance = gracket; + error = null; + } catch (err) { + error = err as Error; + console.error('Gracket initialization error:', err); + } + + return () => { + instance?.destroy(); + instance = null; + }; + }); + + // React to data changes + $effect(() => { + if (instance && data?.length) { + try { + instance.update(data); + error = null; + } catch (err) { + error = err as Error; + } + } + }); + + const update = (newData: TournamentData) => { + try { + instance?.update(newData); + error = null; + } catch (err) { + error = err as Error; + } + }; + + const updateScore = ( + roundIndex: number, + gameIndex: number, + teamIndex: number, + score: number + ) => { + try { + instance?.updateScore(roundIndex, gameIndex, teamIndex, score); + error = null; + } catch (err) { + error = err as Error; + } + }; + + const advanceRound = (fromRound?: number) => { + try { + return instance?.advanceRound(fromRound); + } catch (err) { + error = err as Error; + return undefined; + } + }; + + const destroy = () => { + instance?.destroy(); + instance = null; + }; + + return { + get containerRef() { + return containerRef; + }, + set containerRef(value: HTMLElement | null) { + containerRef = value; + }, + get instance() { + return instance; + }, + get error() { + return error; + }, + update, + updateScore, + advanceRound, + destroy, + }; +} + +/** + * Legacy Svelte 4 store-based approach + * For backward compatibility + */ +export { default as GracketSvelte } from './svelte.svelte'; diff --git a/src/adapters/vue.ts b/src/adapters/vue.ts index 24e8806..6ee5bd2 100644 --- a/src/adapters/vue.ts +++ b/src/adapters/vue.ts @@ -1,30 +1,49 @@ -import { defineComponent, ref, onMounted, onUnmounted, watch, h, PropType } from 'vue'; +import { + defineComponent, + ref, + onMounted, + onUnmounted, + watch, + h, + type PropType, + computed, + shallowRef, +} from 'vue'; import { Gracket } from '../core/Gracket'; import type { GracketOptions, TournamentData } from '../types'; /** - * Vue 3 component wrapper for Gracket + * Vue 3.5+ component wrapper for Gracket with modern best practices */ export const GracketVue = defineComponent({ - name: 'Gracket', + name: 'GracketVue', props: { data: { type: Array as PropType, required: true, }, options: { - type: Object as PropType, + type: Object as PropType>, default: () => ({}), }, class: { type: String, default: '', }, + style: { + type: [Object, String] as PropType | string>, + default: undefined, + }, + }, + emits: { + init: (instance: Gracket) => instance instanceof Gracket, + error: (error: Error) => error instanceof Error, + update: (data: TournamentData) => Array.isArray(data), }, - emits: ['init', 'error'], - setup(props, { emit }) { + setup(props, { emit, expose }) { const containerRef = ref(null); - const gracketInstance = ref(null); + // Use shallowRef for non-reactive DOM instance + const gracketInstance = shallowRef(null); const error = ref(null); const initGracket = () => { @@ -36,46 +55,82 @@ export const GracketVue = defineComponent({ src: props.data, }); + error.value = null; emit('init', gracketInstance.value); } catch (err) { error.value = err as Error; - emit('error', err); + emit('error', err as Error); console.error('Gracket initialization error:', err); } }; + const updateData = (newData: TournamentData) => { + if (gracketInstance.value && newData?.length) { + try { + gracketInstance.value.update(newData); + error.value = null; + emit('update', newData); + } catch (err) { + error.value = err as Error; + emit('error', err as Error); + } + } + }; + + const updateScore = ( + roundIndex: number, + gameIndex: number, + teamIndex: number, + score: number + ) => { + gracketInstance.value?.updateScore(roundIndex, gameIndex, teamIndex, score); + }; + + const advanceRound = (fromRound?: number) => { + return gracketInstance.value?.advanceRound(fromRound); + }; + + const destroy = () => { + gracketInstance.value?.destroy(); + gracketInstance.value = null; + }; + onMounted(() => { initGracket(); }); onUnmounted(() => { - gracketInstance.value?.destroy(); - gracketInstance.value = null; + destroy(); }); - // Watch for data changes + // Watch for data changes with deep comparison watch( () => props.data, - (newData) => { - if (gracketInstance.value && newData?.length) { - gracketInstance.value.update(newData); - } - }, + (newData) => updateData(newData), { deep: true } ); - // Watch for options changes + // Watch for options changes - recreate instance watch( () => props.options, () => { if (gracketInstance.value && props.data?.length) { - gracketInstance.value.destroy(); + destroy(); initGracket(); } }, { deep: true } ); + // Expose public API + expose({ + instance: computed(() => gracketInstance.value), + updateData, + updateScore, + advanceRound, + destroy, + }); + return () => { if (error.value) { return h( @@ -91,9 +146,110 @@ export const GracketVue = defineComponent({ return h('div', { ref: containerRef, class: props.class, + style: props.style, }); }; }, }); +/** + * Composable for programmatic Gracket control - Vue 3.5+ optimized + */ +export const useGracket = ( + data: TournamentData | (() => TournamentData), + options?: GracketOptions | (() => GracketOptions) +) => { + const containerRef = ref(null); + const gracketInstance = shallowRef(null); + const error = ref(null); + + const tournamentData = computed(() => + typeof data === 'function' ? data() : data + ); + + const gracketOptions = computed(() => + typeof options === 'function' ? options() : options + ); + + const init = () => { + if (!containerRef.value || !tournamentData.value?.length) return; + + try { + gracketInstance.value = new Gracket(containerRef.value, { + ...gracketOptions.value, + src: tournamentData.value, + }); + error.value = null; + } catch (err) { + error.value = err as Error; + console.error('Gracket initialization error:', err); + } + }; + + const update = (newData: TournamentData) => { + try { + gracketInstance.value?.update(newData); + error.value = null; + } catch (err) { + error.value = err as Error; + } + }; + + const updateScore = ( + roundIndex: number, + gameIndex: number, + teamIndex: number, + score: number + ) => { + try { + gracketInstance.value?.updateScore(roundIndex, gameIndex, teamIndex, score); + error.value = null; + } catch (err) { + error.value = err as Error; + } + }; + + const advanceRound = (fromRound?: number) => { + try { + return gracketInstance.value?.advanceRound(fromRound); + } catch (err) { + error.value = err as Error; + return undefined; + } + }; + + const destroy = () => { + gracketInstance.value?.destroy(); + gracketInstance.value = null; + }; + + onMounted(() => init()); + onUnmounted(() => destroy()); + + // Watch for data changes + watch(tournamentData, (newData) => { + if (gracketInstance.value && newData?.length) { + update(newData); + } + }, { deep: true }); + + // Watch for options changes + watch(gracketOptions, () => { + if (gracketInstance.value) { + destroy(); + init(); + } + }, { deep: true }); + + return { + containerRef, + instance: computed(() => gracketInstance.value), + error: computed(() => error.value), + update, + updateScore, + advanceRound, + destroy, + }; +}; + export default GracketVue; diff --git a/src/adapters/webcomponent.ts b/src/adapters/webcomponent.ts new file mode 100644 index 0000000..f0c937a --- /dev/null +++ b/src/adapters/webcomponent.ts @@ -0,0 +1,232 @@ +import { Gracket } from '../core/Gracket'; +import type { GracketOptions, TournamentData } from '../types'; + +/** + * Web Component wrapper for Gracket + * Standards-compliant custom element with Shadow DOM support + * + * @example + * ```html + * + * + * + * ``` + */ +export class GracketElement extends HTMLElement { + private gracketInstance: Gracket | null = null; + private container: HTMLDivElement | null = null; + private _data: TournamentData = []; + private _options: Omit = {}; + private shadowRoot: ShadowRoot; + + // Observed attributes for reactive updates + static get observedAttributes() { + return ['data', 'corner-radius', 'canvas-line-color', 'show-bye-games']; + } + + constructor() { + super(); + + // Attach shadow DOM for encapsulation + this.shadowRoot = this.attachShadow({ mode: 'open' }); + + // Create container + this.container = document.createElement('div'); + this.container.className = 'gracket-container'; + + // Add default styles + const style = document.createElement('style'); + style.textContent = ` + :host { + display: block; + width: 100%; + height: 100%; + } + .gracket-container { + width: 100%; + height: 100%; + } + .gracket-error { + color: red; + padding: 1rem; + } + `; + + this.shadowRoot.appendChild(style); + this.shadowRoot.appendChild(this.container); + } + + connectedCallback() { + this.initGracket(); + } + + disconnectedCallback() { + this.destroy(); + } + + attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) { + if (oldValue === newValue) return; + + switch (name) { + case 'data': + if (newValue) { + try { + this._data = JSON.parse(newValue); + this.updateData(); + } catch (err) { + console.error('Invalid JSON data:', err); + } + } + break; + + case 'corner-radius': + this._options.cornerRadius = Number(newValue); + this.reinitialize(); + break; + + case 'canvas-line-color': + this._options.canvasLineColor = newValue || undefined; + this.reinitialize(); + break; + + case 'show-bye-games': + this._options.showByeGames = newValue === 'true'; + this.reinitialize(); + break; + } + } + + // Public API - Getters and Setters + get data(): TournamentData { + return this._data; + } + + set data(value: TournamentData) { + this._data = value; + this.updateData(); + } + + get options(): Omit { + return this._options; + } + + set options(value: Omit) { + this._options = { ...this._options, ...value }; + this.reinitialize(); + } + + // Public methods + public updateScore( + roundIndex: number, + gameIndex: number, + teamIndex: number, + score: number + ): void { + try { + this.gracketInstance?.updateScore(roundIndex, gameIndex, teamIndex, score); + this.dispatchEvent(new CustomEvent('score-updated', { + detail: { roundIndex, gameIndex, teamIndex, score }, + })); + } catch (err) { + this.handleError(err as Error); + } + } + + public advanceRound(fromRound?: number): TournamentData | undefined { + try { + const result = this.gracketInstance?.advanceRound(fromRound); + this.dispatchEvent(new CustomEvent('round-advanced', { + detail: { fromRound, data: result }, + })); + return result; + } catch (err) { + this.handleError(err as Error); + return undefined; + } + } + + public getInstance(): Gracket | null { + return this.gracketInstance; + } + + public destroy(): void { + this.gracketInstance?.destroy(); + this.gracketInstance = null; + } + + // Private methods + private initGracket(): void { + if (!this.container || !this._data?.length) return; + + try { + this.gracketInstance = new Gracket(this.container, { + ...this._options, + src: this._data, + }); + + this.dispatchEvent(new CustomEvent('init', { + detail: { instance: this.gracketInstance }, + })); + } catch (err) { + this.handleError(err as Error); + } + } + + private updateData(): void { + if (!this.gracketInstance) { + this.initGracket(); + return; + } + + if (this._data?.length) { + try { + this.gracketInstance.update(this._data); + this.dispatchEvent(new CustomEvent('update', { + detail: { data: this._data }, + })); + } catch (err) { + this.handleError(err as Error); + } + } + } + + private reinitialize(): void { + this.destroy(); + this.initGracket(); + } + + private handleError(error: Error): void { + console.error('Gracket error:', error); + this.dispatchEvent(new CustomEvent('error', { + detail: { error }, + })); + + if (this.container) { + this.container.innerHTML = ` +
+ Error: ${error.message} +
+ `; + } + } +} + +/** + * Register the custom element + * Call this function to register the element + */ +export function registerGracketElement(tagName: string = 'gracket-bracket'): void { + if (!customElements.get(tagName)) { + customElements.define(tagName, GracketElement); + } +} + +// Auto-register if in browser environment +if (typeof window !== 'undefined' && typeof customElements !== 'undefined') { + registerGracketElement(); +} + +export default GracketElement;