diff --git a/packages/vue/src/composables/index.ts b/packages/vue/src/composables/index.ts index ffc3866..6fa926b 100644 --- a/packages/vue/src/composables/index.ts +++ b/packages/vue/src/composables/index.ts @@ -7,6 +7,7 @@ export * from './useClamp'; export * from './useContextFactory'; export * from './useCounter'; export * from './useFocusGuard'; +export * from './useInjectionStore'; export * from './useLastChanged'; export * from './useMounted'; export * from './useOffsetPagination'; diff --git a/packages/vue/src/composables/useInjectionStore/index.test.ts b/packages/vue/src/composables/useInjectionStore/index.test.ts new file mode 100644 index 0000000..0c42e08 --- /dev/null +++ b/packages/vue/src/composables/useInjectionStore/index.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest'; +import { defineComponent, ref } from 'vue'; +import { useInjectionStore } from '.'; +import { mount } from '@vue/test-utils'; + +function testFactory( + store: ReturnType>, +) { + const { useProvidingState, useInjectedState } = store; + + const Child = defineComponent({ + setup() { + const state = useInjectedState(); + return { state }; + }, + template: `{{ state }}`, + }); + + const Parent = defineComponent({ + components: { Child }, + setup() { + const state = useProvidingState(); + return { state }; + }, + template: ``, + }); + + return { + Parent, + Child, + }; +} + +describe('useInjectionState', () => { + it('provides and injects state correctly', () => { + const { Parent } = testFactory( + useInjectionStore(() => ref('base')) + ); + + const wrapper = mount(Parent); + expect(wrapper.text()).toBe('base'); + }); + + it('injects default value when state is not provided', () => { + const { Child } = testFactory( + useInjectionStore(() => ref('without provider'), { + defaultValue: ref('default'), + injectionKey: 'testKey', + }) + ); + + const wrapper = mount(Child); + expect(wrapper.text()).toBe('default'); + }); + + it('provides state at app level', () => { + const injectionStore = useInjectionStore(() => ref('app level')); + const { Child } = testFactory(injectionStore); + + const wrapper = mount(Child, { + global: { + plugins: [ + app => { + const state = injectionStore.useAppProvidingState(app)(); + expect(state.value).toBe('app level'); + }, + ], + }, + }); + + expect(wrapper.text()).toBe('app level'); + }); + + it('works with custom injection key', () => { + const { Parent } = testFactory( + useInjectionStore(() => ref('custom key'), { + injectionKey: Symbol('customKey'), + }), + ); + + const wrapper = mount(Parent); + expect(wrapper.text()).toBe('custom key'); + }); + + it('handles state factory with arguments', () => { + const injectionStore = useInjectionStore((arg: string) => arg); + const { Child } = testFactory(injectionStore); + + const wrapper = mount(Child, { + global: { + plugins: [ + app => injectionStore.useAppProvidingState(app)('with args'), + ], + }, + }); + + expect(wrapper.text()).toBe('with args'); + }); +}); diff --git a/packages/vue/src/composables/useInjectionStore/index.ts b/packages/vue/src/composables/useInjectionStore/index.ts new file mode 100644 index 0000000..5134f82 --- /dev/null +++ b/packages/vue/src/composables/useInjectionStore/index.ts @@ -0,0 +1,72 @@ +import { inject, provide, type App, type InjectionKey } from 'vue'; + +export interface useInjectionStoreOptions { + injectionKey: string | InjectionKey; + defaultValue?: Return; +} + +/** + * @name useInjectionStore + * @category State + * @description Create a global state that can be injected into components + * + * @param {Function} stateFactory A factory function that creates the state + * @param {useInjectionStoreOptions} options An object with the following properties + * @param {string | InjectionKey} options.injectionKey The key to use for the injection + * @param {any} options.defaultValue The default value to use when the state is not provided + * @returns {Object} An object with `useProvidingState`, `useAppProvidingState`, and `useInjectedState` functions + * + * @example + * const { useProvidingState, useInjectedState } = useInjectionStore(() => ref('Hello World')); + * + * // In a parent component + * const state = useProvidingState(); + * + * // In a child component + * const state = useInjectedState(); + * + * @example + * const { useProvidingState, useInjectedState } = useInjectionStore(() => ref('Hello World'), { + * injectionKey: 'MyState', + * defaultValue: 'Default Value' + * }); + * + * // In a plugin + * { + * install(app) { + * const state = useAppProvidingState(app)(); + * state.value = 'Hello World'; + * } + * } + * + * // In a component + * const state = useInjectedState(); + * + * @since 0.0.5 + */ +export function useInjectionStore( + stateFactory: (...args: Args) => Return, + options?: useInjectionStoreOptions, +) { + const key = options?.injectionKey ?? Symbol(stateFactory.name ?? 'InjectionStore'); + + const useProvidingState = (...args: Args) => { + const state = stateFactory(...args); + provide(key, state); + return state; + }; + + const useAppProvidingState = (app: App) => (...args: Args) => { + const state = stateFactory(...args); + app.provide(key, state); + return state; + }; + + const useInjectedState = () => inject(key, options?.defaultValue); + + return { + useProvidingState, + useAppProvidingState, + useInjectedState + }; +} \ No newline at end of file