From 017ecdbff991d140ea42e4a855269a54e0a19adc Mon Sep 17 00:00:00 2001 From: Wesley Luyten Date: Thu, 18 Sep 2025 17:58:53 -0500 Subject: [PATCH] feat: add compound html timerange component (#14) * fix: add context logic to component factory * feat: add compound time range html component --- examples/html-demo/src/main.ts | 9 +- packages/core/icons/rollup.config.js | 8 +- packages/core/media-store/rollup.config.js | 8 +- packages/html/html-icons/rollup.config.js | 8 +- packages/html/html/package.json | 3 +- packages/html/html/rollup.config.js | 8 +- .../html/src/components/media-time-range.ts | 350 +++++++++++++++--- .../html/html/src/skins/media-skin-default.ts | 49 ++- packages/react/react-icons/rollup.config.js | 8 +- packages/react/react/rollup.config.js | 8 +- .../react/react/src/components/TimeRange.tsx | 63 +--- .../react/src/utils/component-factory.tsx | 35 +- 12 files changed, 424 insertions(+), 133 deletions(-) diff --git a/examples/html-demo/src/main.ts b/examples/html-demo/src/main.ts index 97a682c4..6aa81710 100644 --- a/examples/html-demo/src/main.ts +++ b/examples/html-demo/src/main.ts @@ -4,8 +4,11 @@ defineVjsPlayer(); document.body.innerHTML = ` - - +
+ + + +
-`; \ No newline at end of file +`; diff --git a/packages/core/icons/rollup.config.js b/packages/core/icons/rollup.config.js index a88370aa..ad6f6980 100644 --- a/packages/core/icons/rollup.config.js +++ b/packages/core/icons/rollup.config.js @@ -12,6 +12,9 @@ module.exports = [ format: 'esm', sourcemap: true, }, + watch: { + clearScreen: false + }, external: (id) => { // Don't externalize relative imports (starts with . or /) if (id.startsWith('.') || id.startsWith('/')) return false; @@ -43,6 +46,9 @@ module.exports = [ format: 'cjs', sourcemap: true, }, + watch: { + clearScreen: false + }, external: (id) => { // Don't externalize relative imports (starts with . or /) if (id.startsWith('.') || id.startsWith('/')) return false; @@ -66,4 +72,4 @@ module.exports = [ }), ], }, -]; \ No newline at end of file +]; diff --git a/packages/core/media-store/rollup.config.js b/packages/core/media-store/rollup.config.js index 0fe567ba..e95bc7c6 100644 --- a/packages/core/media-store/rollup.config.js +++ b/packages/core/media-store/rollup.config.js @@ -11,6 +11,9 @@ module.exports = [ format: 'esm', sourcemap: true, }, + watch: { + clearScreen: false + }, external: (id) => { // Don't externalize relative imports (starts with . or /) if (id.startsWith('.') || id.startsWith('/')) return false; @@ -39,6 +42,9 @@ module.exports = [ format: 'cjs', sourcemap: true, }, + watch: { + clearScreen: false + }, external: (id) => { // Don't externalize relative imports (starts with . or /) if (id.startsWith('.') || id.startsWith('/')) return false; @@ -59,4 +65,4 @@ module.exports = [ }), ], }, -]; \ No newline at end of file +]; diff --git a/packages/html/html-icons/rollup.config.js b/packages/html/html-icons/rollup.config.js index 0fe567ba..e95bc7c6 100644 --- a/packages/html/html-icons/rollup.config.js +++ b/packages/html/html-icons/rollup.config.js @@ -11,6 +11,9 @@ module.exports = [ format: 'esm', sourcemap: true, }, + watch: { + clearScreen: false + }, external: (id) => { // Don't externalize relative imports (starts with . or /) if (id.startsWith('.') || id.startsWith('/')) return false; @@ -39,6 +42,9 @@ module.exports = [ format: 'cjs', sourcemap: true, }, + watch: { + clearScreen: false + }, external: (id) => { // Don't externalize relative imports (starts with . or /) if (id.startsWith('.') || id.startsWith('/')) return false; @@ -59,4 +65,4 @@ module.exports = [ }), ], }, -]; \ No newline at end of file +]; diff --git a/packages/html/html/package.json b/packages/html/html/package.json index e3942d09..a9c69b22 100644 --- a/packages/html/html/package.json +++ b/packages/html/html/package.json @@ -17,6 +17,7 @@ ], "scripts": { "build": "rollup -c && tsc --project tsconfig.build.json", + "dev": "rollup -c -w", "test": "echo \"No tests yet\"", "clean": "rm -rf dist" }, @@ -44,4 +45,4 @@ "publishConfig": { "access": "public" } -} \ No newline at end of file +} diff --git a/packages/html/html/rollup.config.js b/packages/html/html/rollup.config.js index 0fe567ba..e95bc7c6 100644 --- a/packages/html/html/rollup.config.js +++ b/packages/html/html/rollup.config.js @@ -11,6 +11,9 @@ module.exports = [ format: 'esm', sourcemap: true, }, + watch: { + clearScreen: false + }, external: (id) => { // Don't externalize relative imports (starts with . or /) if (id.startsWith('.') || id.startsWith('/')) return false; @@ -39,6 +42,9 @@ module.exports = [ format: 'cjs', sourcemap: true, }, + watch: { + clearScreen: false + }, external: (id) => { // Don't externalize relative imports (starts with . or /) if (id.startsWith('.') || id.startsWith('/')) return false; @@ -59,4 +65,4 @@ module.exports = [ }), ], }, -]; \ No newline at end of file +]; diff --git a/packages/html/html/src/components/media-time-range.ts b/packages/html/html/src/components/media-time-range.ts index 366e9f9b..509089d7 100644 --- a/packages/html/html/src/components/media-time-range.ts +++ b/packages/html/html/src/components/media-time-range.ts @@ -5,10 +5,29 @@ import { } from '../utils/component-factory'; import { timeRangeStateDefinition } from '@vjs-10/media-store'; +// Utility functions for pointer position and seek time calculations +const calculatePointerRatio = (clientX: number, rect: DOMRect): number => { + const x = clientX - rect.left; + return Math.max(0, Math.min(100, (x / rect.width) * 100)); +}; + +const calculateSeekTimeFromRatio = (ratio: number, duration: number): number => { + return (ratio / 100) * duration; +}; + +const calculateSeekTimeFromPointerEvent = ( + event: PointerEvent, + duration: number +): number => { + const rect = (event.target as HTMLElement).getBoundingClientRect(); + const ratio = calculatePointerRatio(event.clientX, rect); + return calculateSeekTimeFromRatio(ratio, duration); +}; + /** - * @TODO Should we use a base "range" superclass or just duplicate shared code? (CJP) - **/ -export class TimeRangeBase extends HTMLElement { + * TimeRange Root component - Main container with pointer event handling + */ +export class TimeRangeRootBase extends HTMLElement { _state: | { currentTime: number; @@ -16,35 +35,87 @@ export class TimeRangeBase extends HTMLElement { requestSeek: (time: number) => void; } | undefined; - _input: HTMLInputElement; + _trackElement: HTMLElement | null = null; + _pointerPosition: number | null = null; + _hovering: boolean = false; + _dragging: boolean = false; constructor() { super(); - /** - * @TODO This is just a simple placeholder input to demonstrate functionality. - * A full implementation will need to implement a "compound component" architecture and likely should use templates. (CJP) - **/ - this._input = document.createElement('input'); - this._input.type = 'range'; - this._input.min = '0'; - this._input.max = '100'; - this._input.step = '0.1'; - this._input.addEventListener('input', this); - this.appendChild(this._input); + + // Add pointer event listeners + this.addEventListener('pointerdown', this); + this.addEventListener('pointermove', this); + this.addEventListener('pointerup', this); + this.addEventListener('pointerenter', this); + this.addEventListener('pointerleave', this); } handleEvent(event: Event) { const { type } = event; const state = this._state; - if (state) { - if (type === 'input') { - const ratio = parseFloat(this._input.value) / 100; - const seekTime = ratio * state.duration; - state.requestSeek(seekTime); - } + if (!state) return; + + switch (type) { + case 'pointerdown': + this._handlePointerDown(event as PointerEvent); + break; + case 'pointermove': + this._handlePointerMove(event as PointerEvent); + break; + case 'pointerup': + this._handlePointerUp(event as PointerEvent); + break; + case 'pointerenter': + this._handlePointerEnter(); + break; + case 'pointerleave': + this._handlePointerLeave(); + break; } } + private _handlePointerDown(event: PointerEvent) { + event.preventDefault(); + this._dragging = true; + const seekTime = calculateSeekTimeFromPointerEvent(event, this._state!.duration); + this._state!.requestSeek(seekTime); + + // Capture pointer events + this.setPointerCapture(event.pointerId); + } + + private _handlePointerMove(event: PointerEvent) { + if (!this._trackElement) return; + + const rect = this._trackElement.getBoundingClientRect(); + const ratio = calculatePointerRatio(event.clientX, rect); + this._pointerPosition = ratio; + + if (this._dragging) { + const seekTime = calculateSeekTimeFromRatio(ratio, this._state!.duration); + this._state!.requestSeek(seekTime); + } + } + + private _handlePointerUp(event: PointerEvent) { + this.releasePointerCapture(event.pointerId); + + if (this._dragging && this._trackElement && this._pointerPosition !== null) { + const seekTime = calculateSeekTimeFromRatio(this._pointerPosition, this._state!.duration); + this._state!.requestSeek(seekTime); + } + this._dragging = false; + } + + private _handlePointerEnter() { + this._hovering = true; + } + + private _handlePointerLeave() { + this._hovering = false; + } + get currentTime() { return this._state?.currentTime; } @@ -55,25 +126,106 @@ export class TimeRangeBase extends HTMLElement { _update(props: any, state: any) { this._state = state; - const ratio = - state.duration > 0 ? (state.currentTime / state.duration) * 100 : 0; - this._input.value = ratio.toString(); - this._input.max = '100'; - this._input.setAttribute('aria-label', props['aria-label']); - this._input.setAttribute('aria-valuetext', props['aria-valuetext']); - this._input.disabled = props.disabled ?? false; + + // Find track element + this._trackElement = this.querySelector('media-time-range-track') as HTMLElement; + + // Calculate slider fill percentage + const sliderFill = this._dragging && this._pointerPosition !== null + ? this._pointerPosition + : state.duration > 0 + ? (state.currentTime / state.duration) * 100 + : 0; - // Update data attributes for styling - this.setAttribute('data-current-time', props['data-current-time']); - this.setAttribute('data-duration', props['data-duration']); + // Update CSS custom properties + this.style.setProperty('--slider-fill', `${Math.round(sliderFill)}%`); + this.style.setProperty('--slider-pointer', + this._hovering && this._pointerPosition !== null + ? `${Math.round(this._pointerPosition)}%` + : '0%' + ); + + // Update ARIA attributes + this.setAttribute('role', 'slider'); + this.setAttribute('aria-label', props['aria-label'] || 'Seek'); + this.setAttribute('aria-valuemin', '0'); + this.setAttribute('aria-valuemax', '100'); + this.setAttribute('aria-valuenow', sliderFill.toString()); + this.setAttribute('aria-valuetext', props['aria-valuetext'] || ''); + + // Update data attributes + this.setAttribute('data-current-time', state.currentTime.toString()); + this.setAttribute('data-duration', state.duration.toString()); } } /** - * TimeRange state hook - equivalent to React's useTimeRangeState + * TimeRange Track component - Track element that captures pointer events + */ +export class TimeRangeTrackBase extends HTMLElement { + constructor() { + super(); + } + + _update(_props: any, _state: any) { + // Track doesn't need much state management + } +} + +/** + * TimeRange Progress component - Shows current progress + */ +export class TimeRangeProgressBase extends HTMLElement { + constructor() { + super(); + this.style.position = 'absolute'; + this.style.width = 'var(--slider-fill, 0%)'; + this.style.height = '100%'; + } + + _update(_props: any, _state: any) { + // Progress updates are handled by CSS custom properties + } +} + +/** + * TimeRange Pointer component - Shows hover position + */ +export class TimeRangePointerBase extends HTMLElement { + constructor() { + super(); + this.style.position = 'absolute'; + this.style.width = 'var(--slider-pointer, 0%)'; + this.style.height = '100%'; + } + + _update(_props: any, _state: any) { + // Pointer updates are handled by CSS custom properties + } +} + +/** + * TimeRange Thumb component - Draggable thumb element + */ +export class TimeRangeThumbBase extends HTMLElement { + constructor() { + super(); + this.style.position = 'absolute'; + this.style.top = '50%'; + this.style.left = 'var(--slider-fill, 0%)'; + this.style.transform = 'translate(-50%, -50%)'; + } + + _update(_props: any, _state: any) { + // Thumb updates are handled by CSS custom properties + } +} + +/** + * TimeRange Root state hook - equivalent to React's useTimeRangeRootState * Handles media store state subscription and transformation */ -export const useTimeRangeState: StateHook<{ +export const useTimeRangeRootState: StateHook<{ currentTime: number; duration: number; }> = { @@ -85,10 +237,10 @@ export const useTimeRangeState: StateHook<{ }; /** - * TimeRange props hook - equivalent to React's useTimeRangeProps + * TimeRange Root props hook - equivalent to React's useTimeRangeRootProps * Handles element attributes and properties based on state */ -export const useTimeRangeProps: PropsHook<{ +export const useTimeRangeRootProps: PropsHook<{ currentTime: number; duration: number; }> = (state, _element) => { @@ -108,31 +260,133 @@ export const useTimeRangeProps: PropsHook<{ /** aria attributes/props */ ['aria-label']: 'Seek', ['aria-valuetext']: `${currentTimeText} of ${durationText}`, - /** input props */ - disabled: false, }; return baseProps; }; /** - * @TODO When implementing compound components, this function may need to be swapped out, modified, or augmented in some way or another. (CJP) + * TimeRange Track props hook */ +export const useTimeRangeTrackProps: PropsHook<{}> = (_state, _element) => { + return {}; +}; + /** - * Connected TimeRange component using hook-style architecture - * Equivalent to React's TimeRange = toConnectedComponent(...) + * TimeRange Progress props hook */ -export const TimeRange = toConnectedHTMLComponent( - TimeRangeBase, - useTimeRangeState, - useTimeRangeProps, - 'TimeRange', +export const useTimeRangeProgressProps: PropsHook<{}> = (_state, _element) => { + return {}; +}; + +/** + * TimeRange Pointer props hook + */ +export const useTimeRangePointerProps: PropsHook<{}> = (_state, _element) => { + return {}; +}; + +/** + * TimeRange Thumb props hook + */ +export const useTimeRangeThumbProps: PropsHook<{}> = (_state, _element) => { + return {}; +}; + +/** + * Connected TimeRange Root component using hook-style architecture + */ +export const TimeRangeRoot = toConnectedHTMLComponent( + TimeRangeRootBase, + useTimeRangeRootState, + useTimeRangeRootProps, + 'TimeRangeRoot', ); -// NOTE: In this architecture it will be important to decouple component class definitions from their registration in the CustomElementsRegistry. (CJP) -if (!globalThis.customElements.get('media-time-range')) { +/** + * Connected TimeRange Track component + */ +export const TimeRangeTrack = toConnectedHTMLComponent( + TimeRangeTrackBase, + { keys: [], transform: () => ({}) }, + useTimeRangeTrackProps, + 'TimeRangeTrack', +); + +/** + * Connected TimeRange Progress component + */ +export const TimeRangeProgress = toConnectedHTMLComponent( + TimeRangeProgressBase, + { keys: [], transform: () => ({}) }, + useTimeRangeProgressProps, + 'TimeRangeProgress', +); + +/** + * Connected TimeRange Pointer component + */ +export const TimeRangePointer = toConnectedHTMLComponent( + TimeRangePointerBase, + { keys: [], transform: () => ({}) }, + useTimeRangePointerProps, + 'TimeRangePointer', +); + +/** + * Connected TimeRange Thumb component + */ +export const TimeRangeThumb = toConnectedHTMLComponent( + TimeRangeThumbBase, + { keys: [], transform: () => ({}) }, + useTimeRangeThumbProps, + 'TimeRangeThumb', +); + +/** + * Compound TimeRange component object + */ +export const TimeRange = Object.assign( + {}, + { + Root: TimeRangeRoot, + Track: TimeRangeTrack, + Progress: TimeRangeProgress, + Pointer: TimeRangePointer, + Thumb: TimeRangeThumb, + } +) as { + Root: typeof TimeRangeRoot; + Track: typeof TimeRangeTrack; + Progress: typeof TimeRangeProgress; + Pointer: typeof TimeRangePointer; + Thumb: typeof TimeRangeThumb; +}; + +// Register custom elements +if (!globalThis.customElements.get('media-time-range-root')) { // @ts-ignore - Custom element constructor compatibility - globalThis.customElements.define('media-time-range', TimeRange); + globalThis.customElements.define('media-time-range-root', TimeRangeRoot); +} + +if (!globalThis.customElements.get('media-time-range-track')) { + // @ts-ignore - Custom element constructor compatibility + globalThis.customElements.define('media-time-range-track', TimeRangeTrack); +} + +if (!globalThis.customElements.get('media-time-range-progress')) { + // @ts-ignore - Custom element constructor compatibility + globalThis.customElements.define('media-time-range-progress', TimeRangeProgress); +} + +if (!globalThis.customElements.get('media-time-range-pointer')) { + // @ts-ignore - Custom element constructor compatibility + globalThis.customElements.define('media-time-range-pointer', TimeRangePointer); +} + +if (!globalThis.customElements.get('media-time-range-thumb')) { + // @ts-ignore - Custom element constructor compatibility + globalThis.customElements.define('media-time-range-thumb', TimeRangeThumb); } export default TimeRange; diff --git a/packages/html/html/src/skins/media-skin-default.ts b/packages/html/html/src/skins/media-skin-default.ts index 2ac99d04..c225c653 100644 --- a/packages/html/html/src/skins/media-skin-default.ts +++ b/packages/html/html/src/skins/media-skin-default.ts @@ -23,7 +23,7 @@ export function getTemplateHTML() { color: rgb(238 238 238); } - media-container > [slot=media] { + media-container > ::slotted([slot=media]) { width: 100%; height: 100%; } @@ -103,6 +103,45 @@ export function getTemplateHTML() { .spacer { flex-grow: 1; } + + /* TimeRange Component Styles */ + media-time-range-root { + display: flex; + align-items: center; + position: relative; + min-width: 100px; + width: 100%; + padding-block: .75rem; + margin: 0 .5rem; + } + + media-time-range-track { + position: relative; + width: 100%; + height: .375rem; + background-color: #e0e0e0; + border-radius: .25rem; + overflow: hidden; + pointer-events: none; + } + + media-time-range-thumb { + width: .75rem; + height: .75rem; + background-color: #fff; + border-radius: 50%; + pointer-events: none; + } + + media-time-range-pointer { + background-color: rgba(255, 255, 255, .5); + pointer-events: none; + } + + media-time-range-progress { + background-color: #007bff; + border-radius: inherit; + } @@ -116,7 +155,13 @@ export function getTemplateHTML() { - + + + + + + + diff --git a/packages/react/react-icons/rollup.config.js b/packages/react/react-icons/rollup.config.js index 0fe567ba..e95bc7c6 100644 --- a/packages/react/react-icons/rollup.config.js +++ b/packages/react/react-icons/rollup.config.js @@ -11,6 +11,9 @@ module.exports = [ format: 'esm', sourcemap: true, }, + watch: { + clearScreen: false + }, external: (id) => { // Don't externalize relative imports (starts with . or /) if (id.startsWith('.') || id.startsWith('/')) return false; @@ -39,6 +42,9 @@ module.exports = [ format: 'cjs', sourcemap: true, }, + watch: { + clearScreen: false + }, external: (id) => { // Don't externalize relative imports (starts with . or /) if (id.startsWith('.') || id.startsWith('/')) return false; @@ -59,4 +65,4 @@ module.exports = [ }), ], }, -]; \ No newline at end of file +]; diff --git a/packages/react/react/rollup.config.js b/packages/react/react/rollup.config.js index 8963d658..8587e273 100644 --- a/packages/react/react/rollup.config.js +++ b/packages/react/react/rollup.config.js @@ -12,6 +12,9 @@ module.exports = [ format: 'esm', sourcemap: true, }, + watch: { + clearScreen: false + }, external: [ 'react', '@vjs-10/react-icons', @@ -46,6 +49,9 @@ module.exports = [ format: 'cjs', sourcemap: true, }, + watch: { + clearScreen: false + }, external: [ 'react', '@vjs-10/react-icons', @@ -72,4 +78,4 @@ module.exports = [ }), ], }, -]; \ No newline at end of file +]; diff --git a/packages/react/react/src/components/TimeRange.tsx b/packages/react/react/src/components/TimeRange.tsx index 8357ad6d..19137c40 100644 --- a/packages/react/react/src/components/TimeRange.tsx +++ b/packages/react/react/src/components/TimeRange.tsx @@ -33,29 +33,6 @@ const calculateSeekTimeFromPointerEvent = ( // ROOT COMPONENT // ============================================================================ -interface TimeRangeRootContextValue { - currentTime: number; - duration: number; - requestSeek: (time: number) => void; - pointerPosition: number | null; - setPointerPosition: (position: number | null) => void; - hovering: boolean; - setHovering: (hovering: boolean) => void; - dragging: boolean; - setDragging: (dragging: boolean) => void; - setTrackRef: (ref: HTMLDivElement | null) => void; -} - -const TimeRangeRootContext = React.createContext(null); - -export const useTimeRangeRootContext = (): TimeRangeRootContextValue => { - const context = React.useContext(TimeRangeRootContext); - if (!context) { - throw new Error('useTimeRangeRootContext must be used within a TimeRange.Root component'); - } - return context; -}; - export const useTimeRangeRootState = (_props: any) => { const mediaStore = useMediaStore(); const mediaState = useMediaSelector(timeRangeStateDefinition.stateTransform, shallowEqual); @@ -186,43 +163,11 @@ export const useTimeRangeRootProps = ( type useTimeRangeRootState = typeof useTimeRangeRootState; type useTimeRangeRootProps = typeof useTimeRangeRootProps; -type TimeRangeRootState = ReturnType; type TimeRangeRootProps = ReturnType; -export const renderTimeRangeRoot = (props: TimeRangeRootProps, state: TimeRangeRootState) => { - const contextValue: TimeRangeRootContextValue = React.useMemo( - () => ({ - currentTime: state.currentTime, - duration: state.duration, - requestSeek: state.requestSeek, - pointerPosition: state.pointerPosition, - setPointerPosition: state.setPointerPosition, - hovering: state.hovering, - setHovering: state.setHovering, - dragging: state.dragging, - setDragging: state.setDragging, - setTrackRef: state.setTrackRef, - }), - [ - state.currentTime, - state.duration, - state.requestSeek, - state.pointerPosition, - state.setPointerPosition, - state.hovering, - state.setHovering, - state.dragging, - state.setDragging, - state.setTrackRef, - ] - ); - +export const renderTimeRangeRoot = (props: TimeRangeRootProps) => { return ( - -
- {props.children} -
-
+
); }; @@ -237,8 +182,8 @@ const TimeRangeRoot = toConnectedComponent( // TRACK COMPONENT // ============================================================================ -export const useTimeRangeTrackProps = (props: React.PropsWithChildren<{ [k: string]: any }>) => { - const { setTrackRef } = useTimeRangeRootContext(); +export const useTimeRangeTrackProps = (props: React.PropsWithChildren<{ [k: string]: any }>, context: any) => { + const { setTrackRef } = context; return { ref: setTrackRef, diff --git a/packages/react/react/src/utils/component-factory.tsx b/packages/react/react/src/utils/component-factory.tsx index 894f2958..18aee15d 100644 --- a/packages/react/react/src/utils/component-factory.tsx +++ b/packages/react/react/src/utils/component-factory.tsx @@ -7,18 +7,20 @@ export type StateHookFn = (props: TProps) => TState; export type PropsHookFn = ( props: TProps, - state: TState, + state: TState ) => TResultProps; export type RenderFn = ( props: TProps, - state: TState, + state: TState ) => React.ReactElement; +const Context = React.createContext(null); + /** * Generic factory function to create connected components following the hooks pattern * inspired by Adobe React Spectrum and Base UI architectures. - * + * * @param useStateHook - Hook that provides component state * @param usePropsHook - Hook that enhances props with state-derived values * @param defaultRender - Default render function for the component @@ -29,12 +31,12 @@ export const toConnectedComponent = < TProps extends Record, TState, TResultProps extends Record, - TRenderFn extends RenderFn + TRenderFn extends RenderFn, >( useStateHook: StateHookFn, usePropsHook: PropsHookFn, defaultRender: TRenderFn, - displayName: string, + displayName: string ) => { const ConnectedComponent = ({ render = defaultRender, @@ -42,7 +44,11 @@ export const toConnectedComponent = < }: TProps & { render?: TRenderFn }) => { const connectedState = useStateHook(props as TProps); const connectedProps = usePropsHook(props as TProps, connectedState); - return render(connectedProps, connectedState); + return ( + + {render(connectedProps, connectedState)} + + ); }; ConnectedComponent.displayName = displayName; @@ -54,13 +60,13 @@ export const toConnectedComponent = < */ export type ConnectedComponent< TProps extends Record, - TRenderFn extends RenderFn + TRenderFn extends RenderFn, > = React.FC; /** * Factory function to create context-based components that don't use toConnectedComponent * These components rely on context provided by a parent component. - * + * * @param usePropsHook - Hook that enhances props with context-derived values * @param defaultRender - Default render function for the component * @param displayName - Display name for React DevTools @@ -69,18 +75,19 @@ export type ConnectedComponent< export const toContextComponent = < TProps extends Record, TResultProps extends Record, - TRenderFn extends (props: TResultProps) => React.ReactElement + TRenderFn extends (props: TResultProps, context: any) => React.ReactElement, >( - usePropsHook: (props: TProps) => TResultProps, + usePropsHook: (props: TProps, context: any) => TResultProps, defaultRender: TRenderFn, - displayName: string, + displayName: string ) => { const ContextComponent = ({ render = defaultRender, ...props }: TProps & { render?: TRenderFn }) => { - const contextProps = usePropsHook(props as TProps); - return render(contextProps); + const context = React.useContext(Context); + const contextProps = usePropsHook(props as TProps, context); + return render(contextProps, context); }; ContextComponent.displayName = displayName; @@ -92,5 +99,5 @@ export const toContextComponent = < */ export type ContextComponent< TProps extends Record, - TRenderFn extends (props: any) => React.ReactElement + TRenderFn extends (props: any, context: any) => React.ReactElement, > = React.FC;