From fdcbf96ae6997322f3546e2b2470be273a01e198 Mon Sep 17 00:00:00 2001 From: Mehmet Date: Wed, 9 Aug 2023 12:38:41 +0300 Subject: [PATCH] feat(roving-focus): new package roving-focus (#275) * feat: focus * feat: usecomposeEventHanders * feat: usecomposeEventHanders * fix: focus group * fix: focus * fix: export type * fix: ts * fix: imports * chore: add collection test * fix: primitive * fix: child * fix: event * fix: tab index * fix: roving * fix: collection index * chore: change mounted * fix: test * chore: delete useComposeEventHandlers * chore: add more stories * chore: add playground --- package.json | 1 + packages/components/arrow/src/arrow.ts | 4 +- .../aspect-ratio/src/aspect-ratio.ts | 4 +- packages/components/avatar/src/avatar.ts | 4 +- .../components/avatar/src/avatarFallback.ts | 4 +- packages/components/avatar/src/avatarImage.ts | 4 +- .../components/checkbox/src/bubbleInput.ts | 4 +- packages/components/checkbox/src/checkbox.ts | 6 +- .../checkbox/src/checkboxIndicator.ts | 4 +- .../components/collapsible/src/collapsible.ts | 4 +- .../collapsible/src/collapsibleContent.ts | 4 +- .../collapsible/src/collapsibleContentImpl.ts | 4 +- .../collapsible/src/collapsibleTrigger.ts | 4 +- .../collection/src/collection.test.ts | 62 +++++ .../components/collection/src/collection.ts | 99 +++++--- packages/components/collection/src/index.ts | 2 + packages/components/label/src/label.ts | 4 +- .../components/popper/src/popperAnchor.ts | 4 +- packages/components/popper/src/popperArrow.ts | 4 +- .../components/popper/src/popperContent.ts | 4 +- .../progress/src/progressIndicator.ts | 6 +- packages/components/roving-focus/README.md | 12 + .../components/roving-focus/build.config.ts | 12 + packages/components/roving-focus/package.json | 49 ++++ .../roving-focus/src/RovingFocusGroup.ts | 104 +++++++++ .../roving-focus/src/RovingFocusGroupImpl.ts | 208 +++++++++++++++++ .../roving-focus/src/RovingFocusGroupItem.ts | 179 +++++++++++++++ packages/components/roving-focus/src/index.ts | 2 + .../roving-focus/src/roving-focus.test.ts | 179 +++++++++++++++ .../roving-focus/src/stories/Button.vue | 55 +++++ .../src/stories/ButtonProvide.vue | 11 + .../src/stories/RovingFocusDemo.stories.ts | 71 ++++++ .../src/stories/RovingFocusDemo.vue | 135 +++++++++++ packages/components/roving-focus/src/utils.ts | 52 +++++ .../components/roving-focus/tsconfig.json | 10 + .../components/roving-focus/tsup.config.ts | 22 ++ .../components/separator/src/separator.ts | 6 +- packages/components/slot/package.json | 1 - packages/components/slot/src/slot.ts | 4 +- packages/components/slot/tsup.config.ts | 2 +- packages/components/switch/src/Switch.ts | 4 +- packages/components/switch/src/SwitchThumb.ts | 4 +- packages/components/toggle/src/toggle.ts | 4 +- .../visually-hidden/src/VisuallyHidden.ts | 4 +- packages/core/primitive/package.json | 5 +- packages/core/primitive/src/index.ts | 7 +- packages/core/primitive/src/primitive.test.ts | 71 +++++- packages/core/primitive/src/primitive.ts | 214 ++---------------- packages/core/primitive/src/types.ts | 98 ++++++++ packages/core/primitive/src/utils.ts | 4 + packages/core/provide/src/createProvide.ts | 1 - packages/core/use-composable/src/index.ts | 5 +- playground/nuxt3/package.json | 1 + playground/nuxt3/pages/index.vue | 4 + playground/nuxt3/pages/roving-focus.vue | 5 + playground/vue3/package.json | 2 + playground/vue3/src/pages/index.vue | 8 + playground/vue3/src/pages/roving-focus.vue | 5 + playground/vue3/src/pages/slot.vue | 5 + pnpm-lock.yaml | 70 ++++-- tsconfig.json | 1 + 61 files changed, 1558 insertions(+), 314 deletions(-) create mode 100644 packages/components/collection/src/collection.test.ts create mode 100644 packages/components/roving-focus/README.md create mode 100644 packages/components/roving-focus/build.config.ts create mode 100644 packages/components/roving-focus/package.json create mode 100644 packages/components/roving-focus/src/RovingFocusGroup.ts create mode 100644 packages/components/roving-focus/src/RovingFocusGroupImpl.ts create mode 100644 packages/components/roving-focus/src/RovingFocusGroupItem.ts create mode 100644 packages/components/roving-focus/src/index.ts create mode 100644 packages/components/roving-focus/src/roving-focus.test.ts create mode 100644 packages/components/roving-focus/src/stories/Button.vue create mode 100644 packages/components/roving-focus/src/stories/ButtonProvide.vue create mode 100644 packages/components/roving-focus/src/stories/RovingFocusDemo.stories.ts create mode 100644 packages/components/roving-focus/src/stories/RovingFocusDemo.vue create mode 100644 packages/components/roving-focus/src/utils.ts create mode 100644 packages/components/roving-focus/tsconfig.json create mode 100644 packages/components/roving-focus/tsup.config.ts create mode 100644 playground/nuxt3/pages/roving-focus.vue create mode 100644 playground/vue3/src/pages/roving-focus.vue create mode 100644 playground/vue3/src/pages/slot.vue diff --git a/package.json b/package.json index 3ddcb2d95..1541c605f 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@oku-ui/primitive": "workspace:^", "@oku-ui/progress": "workspace:^", "@oku-ui/provide": "workspace:^", + "@oku-ui/roving-focus": "workspace:^", "@oku-ui/separator": "workspace:^", "@oku-ui/slot": "workspace:^", "@oku-ui/switch": "workspace:^", diff --git a/packages/components/arrow/src/arrow.ts b/packages/components/arrow/src/arrow.ts index e86c88294..6e972804d 100644 --- a/packages/components/arrow/src/arrow.ts +++ b/packages/components/arrow/src/arrow.ts @@ -1,12 +1,12 @@ import { cloneVNode, defineComponent, h } from 'vue' -import type { ElementType, InstanceTypeRef, MergeProps, PrimitiveProps } from '@oku-ui/primitive' +import type { ElementType, IPrimitiveProps, InstanceTypeRef, MergeProps } from '@oku-ui/primitive' import { Primitive } from '@oku-ui/primitive' import { useForwardRef } from '@oku-ui/use-composable' type ArrowElement = ElementType<'svg'> export type _ArrowEl = SVGSVGElement -interface ArrowProps extends PrimitiveProps {} +interface ArrowProps extends IPrimitiveProps {} const NAME = 'Arrow' diff --git a/packages/components/aspect-ratio/src/aspect-ratio.ts b/packages/components/aspect-ratio/src/aspect-ratio.ts index a9efedb27..8d9f6a612 100644 --- a/packages/components/aspect-ratio/src/aspect-ratio.ts +++ b/packages/components/aspect-ratio/src/aspect-ratio.ts @@ -1,9 +1,9 @@ import { defineComponent, h } from 'vue' -import type { ElementType, InstanceTypeRef, MergeProps, PrimitiveProps } from '@oku-ui/primitive' +import type { ElementType, IPrimitiveProps, InstanceTypeRef, MergeProps } from '@oku-ui/primitive' import { Primitive } from '@oku-ui/primitive' import { useForwardRef } from '@oku-ui/use-composable' -interface AspectRatioProps extends PrimitiveProps { +interface AspectRatioProps extends IPrimitiveProps { ratio?: number } diff --git a/packages/components/avatar/src/avatar.ts b/packages/components/avatar/src/avatar.ts index 07694a4c9..20f66a45a 100644 --- a/packages/components/avatar/src/avatar.ts +++ b/packages/components/avatar/src/avatar.ts @@ -1,6 +1,6 @@ import type { PropType } from 'vue' import { defineComponent, h, ref } from 'vue' -import type { ElementType, InstanceTypeRef, MergeProps, PrimitiveProps } from '@oku-ui/primitive' +import type { ElementType, IPrimitiveProps, InstanceTypeRef, MergeProps } from '@oku-ui/primitive' import { Primitive } from '@oku-ui/primitive' import type { Scope } from '@oku-ui/provide' import { createProvideScope } from '@oku-ui/provide' @@ -21,7 +21,7 @@ export const [AvatarProvider, useAvatarInject] = createAvatarProvide export type _AvatarEl = HTMLSpanElement -interface AvatarProps extends PrimitiveProps { +interface AvatarProps extends IPrimitiveProps { scopeAvatar?: Scope } diff --git a/packages/components/avatar/src/avatarFallback.ts b/packages/components/avatar/src/avatarFallback.ts index afe3dad6d..657e7e319 100644 --- a/packages/components/avatar/src/avatarFallback.ts +++ b/packages/components/avatar/src/avatarFallback.ts @@ -1,6 +1,6 @@ import type { PropType } from 'vue' import { defineComponent, h, onMounted, ref, watchEffect } from 'vue' -import type { ElementType, InstanceTypeRef, MergeProps, PrimitiveProps } from '@oku-ui/primitive' +import type { ElementType, IPrimitiveProps, InstanceTypeRef, MergeProps } from '@oku-ui/primitive' import { Primitive } from '@oku-ui/primitive' import type { Scope } from '@oku-ui/provide' import { useForwardRef } from '@oku-ui/use-composable' @@ -11,7 +11,7 @@ const FALLBACK_NAME = 'OkuAvatarFallback' type AvatarFallbackElement = ElementType<'span'> export type _AvatarFalbackEl = HTMLSpanElement -interface AvatarFallbackProps extends PrimitiveProps { +interface AvatarFallbackProps extends IPrimitiveProps { delayMs?: number } diff --git a/packages/components/avatar/src/avatarImage.ts b/packages/components/avatar/src/avatarImage.ts index c94897713..c6203c0a2 100644 --- a/packages/components/avatar/src/avatarImage.ts +++ b/packages/components/avatar/src/avatarImage.ts @@ -1,6 +1,6 @@ import type { PropType } from 'vue' import { defineComponent, h, onMounted, toRefs, watch } from 'vue' -import type { ElementType, InstanceTypeRef, MergeProps, PrimitiveProps } from '@oku-ui/primitive' +import type { ElementType, IPrimitiveProps, InstanceTypeRef, MergeProps } from '@oku-ui/primitive' import { Primitive } from '@oku-ui/primitive' import type { Scope } from '@oku-ui/provide' import { useCallbackRef, useForwardRef } from '@oku-ui/use-composable' @@ -13,7 +13,7 @@ const IMAGE_NAME = 'AvatarImage' type AvatarImageElement = ElementType<'img'> export type _AvatarImageEl = HTMLImageElement -interface AvatarImageProps extends PrimitiveProps { +interface AvatarImageProps extends IPrimitiveProps { onLoadingStatusChange?: (status: ImageLoadingStatus) => void scopeAvatar?: Scope } diff --git a/packages/components/checkbox/src/bubbleInput.ts b/packages/components/checkbox/src/bubbleInput.ts index 40d7efd0f..6570f1f72 100644 --- a/packages/components/checkbox/src/bubbleInput.ts +++ b/packages/components/checkbox/src/bubbleInput.ts @@ -3,13 +3,13 @@ import { defineComponent, h, ref, toRefs, watchEffect } from 'vue' import { usePrevious, useSize } from '@oku-ui/use-composable' -import type { ElementType, PrimitiveProps } from '@oku-ui/primitive' +import type { ElementType, IPrimitiveProps } from '@oku-ui/primitive' import { type CheckedState, isIndeterminate } from './utils' type BubbleInputElement = ElementType<'input'> -interface BubbleInputProps extends PrimitiveProps { +interface BubbleInputProps extends IPrimitiveProps { checked: Ref control: HTMLElement | null bubbles: boolean diff --git a/packages/components/checkbox/src/checkbox.ts b/packages/components/checkbox/src/checkbox.ts index 83767bbb6..961357604 100644 --- a/packages/components/checkbox/src/checkbox.ts +++ b/packages/components/checkbox/src/checkbox.ts @@ -6,7 +6,7 @@ import { composeEventHandlers } from '@oku-ui/utils' import { useComposedRefs, useControllable, useForwardRef } from '@oku-ui/use-composable' import { Primitive } from '@oku-ui/primitive' -import type { ComponentPublicInstanceRef, ElementType, InstanceTypeRef, MergeProps, PrimitiveProps } from '@oku-ui/primitive' +import type { ComponentPublicInstanceRef, ElementType, IPrimitiveProps, InstanceTypeRef, MergeProps } from '@oku-ui/primitive' import type { Scope } from '@oku-ui/provide' import { type CheckedState, getState, isIndeterminate } from './utils' @@ -27,7 +27,7 @@ export const [CheckboxProvider, useCheckboxInject] type CheckboxElement = ElementType<'button'> export type _CheckboxEl = HTMLButtonElement -interface CheckboxProps extends PrimitiveProps { +interface CheckboxProps extends IPrimitiveProps { checked?: CheckedState defaultChecked?: CheckedState required?: boolean @@ -176,7 +176,7 @@ const Checkbox = defineComponent({ type CheckboxIndicatorElement = ElementType<'span'> -interface CheckboxIndicatorProps extends PrimitiveProps { +interface CheckboxIndicatorProps extends IPrimitiveProps { forceMount?: true } diff --git a/packages/components/checkbox/src/checkboxIndicator.ts b/packages/components/checkbox/src/checkboxIndicator.ts index 9caee1aed..d25697236 100644 --- a/packages/components/checkbox/src/checkboxIndicator.ts +++ b/packages/components/checkbox/src/checkboxIndicator.ts @@ -4,7 +4,7 @@ import { Transition, defineComponent, h, toRefs } from 'vue' import { useForwardRef } from '@oku-ui/use-composable' import { Primitive } from '@oku-ui/primitive' -import type { ElementType, InstanceTypeRef, MergeProps, PrimitiveProps } from '@oku-ui/primitive' +import type { ElementType, IPrimitiveProps, InstanceTypeRef, MergeProps } from '@oku-ui/primitive' import type { Scope } from '@oku-ui/provide' import { getState, isIndeterminate } from './utils' @@ -13,7 +13,7 @@ import { useCheckboxInject } from './checkbox' type CheckboxIndicatorElement = ElementType<'span'> export type _CheckboxIndicatorEl = HTMLSpanElement -interface CheckboxIndicatorProps extends PrimitiveProps { +interface CheckboxIndicatorProps extends IPrimitiveProps { forceMount?: true } diff --git a/packages/components/collapsible/src/collapsible.ts b/packages/components/collapsible/src/collapsible.ts index f242059a4..5165dedcf 100644 --- a/packages/components/collapsible/src/collapsible.ts +++ b/packages/components/collapsible/src/collapsible.ts @@ -1,6 +1,6 @@ import type { PropType, Ref } from 'vue' import { computed, defineComponent, h, toRefs, useModel } from 'vue' -import type { ElementType, InstanceTypeRef, MergeProps, PrimitiveProps } from '@oku-ui/primitive' +import type { ElementType, IPrimitiveProps, InstanceTypeRef, MergeProps } from '@oku-ui/primitive' import type { Scope } from '@oku-ui/provide' import { createProvideScope } from '@oku-ui/provide' import { Primitive } from '@oku-ui/primitive' @@ -8,7 +8,7 @@ import { Primitive } from '@oku-ui/primitive' import { useControllable, useForwardRef, useId } from '@oku-ui/use-composable' import { getState } from './utils' -interface CollapsibleProps extends PrimitiveProps { +interface CollapsibleProps extends IPrimitiveProps { } type CollapsibleElement = ElementType<'div'> export type _CollapsibleEl = HTMLDivElement diff --git a/packages/components/collapsible/src/collapsibleContent.ts b/packages/components/collapsible/src/collapsibleContent.ts index d6d5e4720..b816a9681 100644 --- a/packages/components/collapsible/src/collapsibleContent.ts +++ b/packages/components/collapsible/src/collapsibleContent.ts @@ -3,7 +3,7 @@ import { Transition, defineComponent, h, toRefs } from 'vue' import type { Scope } from '@oku-ui/provide' import { useForwardRef } from '@oku-ui/use-composable' -import type { ElementType, InstanceTypeRef, MergeProps, PrimitiveProps } from '@oku-ui/primitive' +import type { ElementType, IPrimitiveProps, InstanceTypeRef, MergeProps } from '@oku-ui/primitive' import { OkuPresence } from '@oku-ui/presence' import { OkuCollapsibleContentImpl } from './collapsibleContentImpl' import { useCollapsibleInject } from './collapsible' @@ -13,7 +13,7 @@ export const CONTENT_NAME = 'CollapsibleContent' type CollapsibleContentElement = ElementType<'div'> export type _CollapsibleContentEl = HTMLDivElement -interface CollapsibleContentProps extends PrimitiveProps { +interface CollapsibleContentProps extends IPrimitiveProps { } const CollapsibleContent = defineComponent({ diff --git a/packages/components/collapsible/src/collapsibleContentImpl.ts b/packages/components/collapsible/src/collapsibleContentImpl.ts index 78fbaaf9c..0e5293bd8 100644 --- a/packages/components/collapsible/src/collapsibleContentImpl.ts +++ b/packages/components/collapsible/src/collapsibleContentImpl.ts @@ -1,6 +1,6 @@ import type { PropType, Ref } from 'vue' import { computed, defineComponent, h, nextTick, onMounted, ref, toRefs, watch, watchEffect } from 'vue' -import type { ComponentPublicInstanceRef, ElementType, InstanceTypeRef, MergeProps, PrimitiveProps } from '@oku-ui/primitive' +import type { ComponentPublicInstanceRef, ElementType, IPrimitiveProps, InstanceTypeRef, MergeProps } from '@oku-ui/primitive' import type { Scope } from '@oku-ui/provide' import { Primitive } from '@oku-ui/primitive' @@ -12,7 +12,7 @@ import { CONTENT_NAME } from './collapsibleContent' type CollapsibleContentImplElement = ElementType<'div'> export type _CollapsibleContentImplEl = HTMLDivElement -interface CollapsibleContentImplProps extends PrimitiveProps { } +interface CollapsibleContentImplProps extends IPrimitiveProps { } const CollapsibleContentImpl = defineComponent({ inheritAttrs: false, diff --git a/packages/components/collapsible/src/collapsibleTrigger.ts b/packages/components/collapsible/src/collapsibleTrigger.ts index 20c5327f3..c1b752290 100644 --- a/packages/components/collapsible/src/collapsibleTrigger.ts +++ b/packages/components/collapsible/src/collapsibleTrigger.ts @@ -1,7 +1,7 @@ import type { PropType } from 'vue' import { defineComponent, h, toRefs } from 'vue' import type { Scope } from '@oku-ui/provide' -import type { ElementType, InstanceTypeRef, MergeProps, PrimitiveProps } from '@oku-ui/primitive' +import type { ElementType, IPrimitiveProps, InstanceTypeRef, MergeProps } from '@oku-ui/primitive' import { Primitive } from '@oku-ui/primitive' import { composeEventHandlers } from '@oku-ui/utils' @@ -14,7 +14,7 @@ const TRIGGER_NAME = 'OkuCollapsibleTrigger' type CollapsibleTriggerElement = ElementType<'button'> export type _CollapsibleTriggerEl = HTMLButtonElement -interface CollapsibleTriggerProps extends PrimitiveProps { } +interface CollapsibleTriggerProps extends IPrimitiveProps { } const CollapsibleTrigger = defineComponent({ name: TRIGGER_NAME, diff --git a/packages/components/collection/src/collection.test.ts b/packages/components/collection/src/collection.test.ts new file mode 100644 index 000000000..8ddf4d794 --- /dev/null +++ b/packages/components/collection/src/collection.test.ts @@ -0,0 +1,62 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { h, ref, watchEffect } from 'vue' +import { createCollection } from './collection' + +describe('collection', () => { + it('renders the component correctly', () => { + type ItemData = { disabled?: boolean } + + const { CollectionSlot, CollectionItemSlot, CollectionProvider, useCollection } = createCollection('List', { + disabled: { + type: Boolean, + default: false, + }, + }) + const TestComponent = { + components: { + CollectionSlot, + }, + setup() { + function LogsItem() { + const data = useCollection('List') + const refData = ref() + watchEffect(() => { + if (data.value) + refData.value = data.value + }) + if (refData.value?.[2]?.disabled) + expect('disabled').toBe('disabled') + } + + return () => h(CollectionProvider, {}, { + default: () => h(CollectionSlot, {}, { + default: () => h('ul', { class: 'w-20' }, { + default: () => [ + h(CollectionItemSlot, {}, { + default: () => h('li', {}, 'Item 1'), + }), + h(CollectionItemSlot, {}, { + default: () => h('li', {}, 'Item 2'), + }), + h(CollectionItemSlot, { disabled: true }, { + default: () => h('li', {}, 'Item 3'), + }), + h(LogsItem), + ], + }), + }), + }) + }, + } + const wrapper = mount(TestComponent) + + expect(wrapper.html()).toBe(`
    +
  • Item 1
  • +
  • Item 2
  • +
  • Item 3
  • + +
`, + ) + }) +}) diff --git a/packages/components/collection/src/collection.ts b/packages/components/collection/src/collection.ts index 23cfcbef3..5a02c7623 100644 --- a/packages/components/collection/src/collection.ts +++ b/packages/components/collection/src/collection.ts @@ -1,11 +1,18 @@ -import type { FunctionalComponent, Ref, ReservedProps } from 'vue' -import { computed, defineComponent, h, ref, watchEffect } from 'vue' +import type { AllowedComponentProps, ComponentCustomProps, ComponentObjectPropsOptions, ComponentPublicInstance, Ref, VNodeProps } from 'vue' +import { computed, createVNode, defineComponent, h, ref, watchEffect } from 'vue' import { useComposedRefs, useForwardRef } from '@oku-ui/use-composable' import { createProvideScope } from '@oku-ui/provide' import { OkuSlot } from '@oku-ui/slot' const CollectionProps = { - scope: { type: null as any, required: true }, + scope: { type: null as any, required: false }, +} +interface CollectionPropsType { + scope: any +} + +type ComponentPublicInstanceRef = Omit & { + $el: T } type CollectionElement = HTMLElement @@ -15,10 +22,7 @@ type CollectionElement = HTMLElement // This is because we encountered issues with generic types that cannot be statically analysed // due to creating them dynamically via createCollection. -function createCollection(name: string) { - // const ItemData = { - // scope: { type: Object, required: true }, - // } +function createCollection(name: string, ItemData: ComponentObjectPropsOptions) { /* ----------------------------------------------------------------------------------------------- * CollectionProvider * --------------------------------------------------------------------------------------------- */ @@ -27,13 +31,15 @@ function createCollection(name: strin const [createCollectionProvide, createCollectionScope] = createProvideScope(PROVIDER_NAME) type ContextValue = { - collectionRef: Ref - itemMap: Map, { ref: Ref } & ItemData> + collectionRef: Ref | undefined> + itemMap: Ref | null | undefined>, { + ref: ComponentPublicInstanceRef + } & T>> } const [CollectionProviderImpl, useCollectionInject] = createCollectionProvide( PROVIDER_NAME, - { collectionRef: ref(undefined), itemMap: new Map() }, + { collectionRef: ref(undefined), itemMap: ref(new Map()) }, ) const CollectionProvider = defineComponent({ @@ -43,8 +49,8 @@ function createCollection(name: strin ...CollectionProps, }, setup(props, { slots }) { - const collectionRef = ref() - const itemMap = new Map, { ref: Ref } & ItemData>() + const collectionRef = ref>() + const itemMap = ref(new Map | null | undefined>, { ref: ComponentPublicInstanceRef } & T>()) CollectionProviderImpl({ collectionRef, itemMap, @@ -62,17 +68,19 @@ function createCollection(name: strin const CollectionSlot = defineComponent({ name: COLLECTION_SLOT_NAME, + components: { + OkuSlot, + }, inheritAttrs: false, props: { ...CollectionProps, + ...ItemData, }, setup(props, { slots }) { const inject = useCollectionInject(COLLECTION_SLOT_NAME, props.scope) const forwaredRef = useForwardRef() const composedRefs = useComposedRefs(forwaredRef, inject.value.collectionRef) - return () => h(OkuSlot, { ref: composedRefs }, { - default: () => slots.default?.(), - }) + return () => h(OkuSlot, { ref: composedRefs }, slots) }, }) @@ -83,26 +91,42 @@ function createCollection(name: strin const ITEM_SLOT_NAME = `${name}CollectionItemSlot` const ITEM_DATA_ATTR = 'data-oku-collection-item' - type CollectionItemSlotProps = ItemData & { - scope: any | undefined - } & ReservedProps + const _CollectionItemSlot = defineComponent({ + name: ITEM_SLOT_NAME, + components: { + OkuSlot, + }, + inheritAttrs: false, + props: { + ...CollectionProps, + ...ItemData, + }, + setup(props, { attrs, slots }) { + const { scope, ...itemData } = props + const refValue = ref | null>() + const forwaredRef = useForwardRef() + const composedRefs = useComposedRefs(refValue, forwaredRef) - const CollectionItemSlot: FunctionalComponent = (props, context) => { - const { scope, ...itemData } = props - const refValue = ref() - const forwaredRef = useForwardRef() - const composedRefs = useComposedRefs(refValue, forwaredRef) + const inject = useCollectionInject(ITEM_SLOT_NAME, scope) - const inject = useCollectionInject(ITEM_SLOT_NAME, scope) + watchEffect((onClean) => { + inject.value.itemMap.value.set(refValue, { ref: refValue, ...(itemData as any), ...attrs }) - watchEffect((clearMap) => { - inject.value.itemMap.set(refValue, { ref: refValue, ...(itemData as any) }) - clearMap(() => inject.value.itemMap.delete(refValue)) - }) + onClean(() => { + inject.value.itemMap.value.delete(refValue) + }) + }) - return h(OkuSlot, { ref: composedRefs, ...{ [ITEM_DATA_ATTR]: '' } }, { - default: () => context.slots.default?.(), - }) + return () => createVNode(OkuSlot, { ref: composedRefs, ...{ [ITEM_DATA_ATTR]: '' } }, slots) + }, + }) + + const CollectionItemSlot = _CollectionItemSlot as unknown as { + new(): { + $props: AllowedComponentProps & + ComponentCustomProps & + VNodeProps & T + } } /* ----------------------------------------------------------------------------------------------- @@ -111,22 +135,21 @@ function createCollection(name: strin function useCollection(scope: any) { const inject = useCollectionInject(`${name}CollectionConsumer`, scope) - const getItems = computed(() => { - const collectionNode = inject.value.collectionRef.value + const collectionNode = inject.value.collectionRef.value?.$el if (!collectionNode) return [] const orderedNodes = Array.from(collectionNode.querySelectorAll(`[${ITEM_DATA_ATTR}]`)) - const items = Array.from(inject.value.itemMap.values()) - + const items = Array.from(inject.value.itemMap.value.values()) const orderedItems = items.sort( - (a, b) => orderedNodes.indexOf(a.ref.value!) - orderedNodes.indexOf(b.ref.value!), + (a, b) => { + return orderedNodes.indexOf(a.ref.$el!) - orderedNodes.indexOf(b.ref.$el!) + }, ) return orderedItems }) - return getItems } @@ -140,3 +163,5 @@ function createCollection(name: strin } export { createCollection, CollectionProps } + +export type { CollectionElement, CollectionPropsType } diff --git a/packages/components/collection/src/index.ts b/packages/components/collection/src/index.ts index 165abca29..694b778a5 100644 --- a/packages/components/collection/src/index.ts +++ b/packages/components/collection/src/index.ts @@ -2,3 +2,5 @@ export { createCollection, CollectionProps, } from './collection' + +export type { CollectionElement, CollectionPropsType } from './collection' diff --git a/packages/components/label/src/label.ts b/packages/components/label/src/label.ts index 9ced659a4..900153b24 100644 --- a/packages/components/label/src/label.ts +++ b/packages/components/label/src/label.ts @@ -1,11 +1,11 @@ import { defineComponent, h } from 'vue' -import type { ElementType, InstanceTypeRef, MergeProps, PrimitiveProps } from '@oku-ui/primitive' +import type { ElementType, IPrimitiveProps, InstanceTypeRef, MergeProps } from '@oku-ui/primitive' import { Primitive } from '@oku-ui/primitive' import { useForwardRef } from '@oku-ui/use-composable' type LabelElement = ElementType<'label'> export type _LabelEl = HTMLLabelElement -interface LabelProps extends PrimitiveProps {} +interface LabelProps extends IPrimitiveProps {} const NAME = 'Label' diff --git a/packages/components/popper/src/popperAnchor.ts b/packages/components/popper/src/popperAnchor.ts index 058031527..343f46950 100644 --- a/packages/components/popper/src/popperAnchor.ts +++ b/packages/components/popper/src/popperAnchor.ts @@ -4,9 +4,9 @@ import { defineComponent, h, ref, toRefs, watch } from 'vue' import type { ComponentPublicInstanceRef, ElementType, + IPrimitiveProps, InstanceTypeRef, MergeProps, - PrimitiveProps, } from '@oku-ui/primitive' import type { Measurable } from '@oku-ui/utils' import type { Scope } from '@oku-ui/provide' @@ -23,7 +23,7 @@ const ANCHOR_NAME = 'PopperAnchor' type PopperAnchorElement = ElementType<'div'> export type _PopperAnchorEl = HTMLDivElement -interface PopperAnchorProps extends PrimitiveProps { +interface PopperAnchorProps extends IPrimitiveProps { virtualRef?: Ref scopeCheckbox?: Scope } diff --git a/packages/components/popper/src/popperArrow.ts b/packages/components/popper/src/popperArrow.ts index fb07447ab..18bae9d4c 100644 --- a/packages/components/popper/src/popperArrow.ts +++ b/packages/components/popper/src/popperArrow.ts @@ -3,9 +3,9 @@ import { computed, defineComponent, h } from 'vue' import type { ElementType, + IPrimitiveProps, InstanceTypeRef, MergeProps, - PrimitiveProps, } from '@oku-ui/primitive' import type { Scope } from '@oku-ui/provide' import type { ArrowProps, _ArrowEl } from '@oku-ui/arrow' @@ -24,7 +24,7 @@ const OPPOSITE_SIDE: Record = { } type PopperArrowElement = ElementType<'svg'> -interface PopperArrowProps extends PrimitiveProps, ArrowProps { +interface PopperArrowProps extends IPrimitiveProps, ArrowProps { scopePopper?: Scope } diff --git a/packages/components/popper/src/popperContent.ts b/packages/components/popper/src/popperContent.ts index 33100bcd3..51babdc03 100644 --- a/packages/components/popper/src/popperContent.ts +++ b/packages/components/popper/src/popperContent.ts @@ -2,7 +2,7 @@ import type { PropType, Ref, StyleValue } from 'vue' import { computed, defineComponent, h, onMounted, ref, toRefs, watch, watchEffect } from 'vue' import { Primitive } from '@oku-ui/primitive' -import type { ComponentPublicInstanceRef, ElementType, InstanceTypeRef, MergeProps, PrimitiveProps } from '@oku-ui/primitive' +import type { ComponentPublicInstanceRef, ElementType, IPrimitiveProps, InstanceTypeRef, MergeProps } from '@oku-ui/primitive' import type { Scope } from '@oku-ui/provide' import { computedEager, useCallbackRef, useComposedRefs, useForwardRef, useSize } from '@oku-ui/use-composable' import { autoUpdate, flip, arrow as floatingUIarrow, hide, limitShift, offset, shift, size, useFloating } from '@floating-ui/vue' @@ -36,7 +36,7 @@ type Boundary = Element | null type PopperContentElement = ElementType<'div'> export type _PopperContentEl = HTMLDivElement -interface PopperContentProps extends PrimitiveProps { +interface PopperContentProps extends IPrimitiveProps { side?: Side sideOffset?: number align?: Align diff --git a/packages/components/progress/src/progressIndicator.ts b/packages/components/progress/src/progressIndicator.ts index 1e3c8eba1..458de0ece 100644 --- a/packages/components/progress/src/progressIndicator.ts +++ b/packages/components/progress/src/progressIndicator.ts @@ -2,9 +2,9 @@ import type { PropType } from 'vue' import { defineComponent, h } from 'vue' import type { ElementType, + IPrimitiveProps, InstanceTypeRef, MergeProps, - PrimitiveProps, } from '@oku-ui/primitive' import type { Scope } from '@oku-ui/provide' import { useForwardRef } from '@oku-ui/use-composable' @@ -15,7 +15,7 @@ import { INDICATOR_NAME } from './constants' type ProgressIndicatorElement = ElementType<'div'> export type _ProgressIndicatorEl = HTMLDivElement -interface ProgressIndicatorProps extends PrimitiveProps { +interface ProgressIndicatorProps extends IPrimitiveProps { scopeProgress?: Scope } @@ -60,7 +60,7 @@ const ProgressIndicator = defineComponent({ type _OkuProgressIndicatorProps = MergeProps< ProgressIndicatorProps, - PrimitiveProps + ProgressIndicatorElement > type InstanceProgressIndicatorType = InstanceTypeRef diff --git a/packages/components/roving-focus/README.md b/packages/components/roving-focus/README.md new file mode 100644 index 000000000..c3e78aabd --- /dev/null +++ b/packages/components/roving-focus/README.md @@ -0,0 +1,12 @@ +# `@oku-ui/roving-focus` + +Version | Downloads | Website + +## Installation + +```sh +$ pnpm add @oku-ui/roving-focus +``` + +## Usage +... diff --git a/packages/components/roving-focus/build.config.ts b/packages/components/roving-focus/build.config.ts new file mode 100644 index 000000000..b972b9a78 --- /dev/null +++ b/packages/components/roving-focus/build.config.ts @@ -0,0 +1,12 @@ +import { defineBuildConfig } from 'unbuild' + +export default defineBuildConfig({ + entries: [ + { + builder: 'mkdist', + input: './src/', + pattern: ['**/!(*.test|*.stories).ts'], + }, + ], + declaration: true, +}) diff --git a/packages/components/roving-focus/package.json b/packages/components/roving-focus/package.json new file mode 100644 index 000000000..93770a086 --- /dev/null +++ b/packages/components/roving-focus/package.json @@ -0,0 +1,49 @@ +{ + "name": "@oku-ui/roving-focus", + "type": "module", + "version": "0.2.3", + "license": "MIT", + "source": "src/index.ts", + "funding": "https://github.com/sponsors/productdevbook", + "homepage": "https://oku-ui.com/primitives", + "repository": { + "type": "git", + "url": "git+https://github.com/oku-ui/primitives.git", + "directory": "packages/components/roving-focus" + }, + "bugs": { + "url": "https://github.com/oku-ui/primitives/issues" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs" + } + }, + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "engines": { + "node": ">=18" + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch" + }, + "peerDependencies": { + "vue": "^3.3.0" + }, + "dependencies": { + "@oku-ui/collection": "latest", + "@oku-ui/direction": "latest", + "@oku-ui/primitive": "latest", + "@oku-ui/provide": "latest", + "@oku-ui/use-composable": "latest", + "@oku-ui/utils": "latest" + }, + "devDependencies": { + "tsconfig": "workspace:^" + } +} diff --git a/packages/components/roving-focus/src/RovingFocusGroup.ts b/packages/components/roving-focus/src/RovingFocusGroup.ts new file mode 100644 index 000000000..7a97892fb --- /dev/null +++ b/packages/components/roving-focus/src/RovingFocusGroup.ts @@ -0,0 +1,104 @@ +import { createProvideScope } from '@oku-ui/provide' +import type { CollectionPropsType } from '@oku-ui/collection' +import { createCollection } from '@oku-ui/collection' +import type { Scope } from '@oku-ui/provide' +import type { ComputedRef, PropType } from 'vue' +import { createVNode, defineComponent, h, mergeProps } from 'vue' +import { useForwardRef } from '@oku-ui/use-composable' +import type { MergeProps } from '@oku-ui/primitive' +import { IRovingFocusGroupImplProps, OkuRovingFocusGroupImpl } from './RovingFocusGroupImpl' +import type { RovingFocusGroupImplElement, RovingFocusGroupImplPropsType, RovingFocusGroupOptions } from './RovingFocusGroupImpl' + +const GROUP_NAME = 'RovingFocusGroup' + +export interface ItemData extends CollectionPropsType { + id: string + focusable: boolean + active: boolean +} + +export const { CollectionItemSlot, CollectionProvider, CollectionSlot, useCollection, createCollectionScope } = createCollection< + HTMLSpanElement, + ItemData +>(GROUP_NAME, { + id: { + type: String, + }, + focusable: { + type: Boolean, + }, + active: { + type: Boolean, + }, +}) + +export type ScopedPropsInterface

= P & { scopeRovingFocusGroup?: Scope } +export const ScopedProps = { + scopeRovingFocusGroup: { + type: Object as PropType, + }, +} + +const [createRovingFocusGroupProvide, createRovingFocusGroupScope] = createProvideScope( + GROUP_NAME, + [createCollectionScope], +) + +type RovingContextValue = RovingFocusGroupOptions & { + currentTabStopId: ComputedRef + onItemFocus(tabStopId: string): void + onItemShiftTab(): void + onFocusableItemAdd(): void + onFocusableItemRemove(): void +} + +export const [useRovingFocusProvider, useRovingFocusInject] + = createRovingFocusGroupProvide(GROUP_NAME) + +type RovingFocusGroupElement = RovingFocusGroupImplElement +interface IRovingFocusGroup extends ScopedPropsInterface { } + +const RovingFocusGroupProps = { + ...IRovingFocusGroupImplProps, + ...ScopedProps, +} + +const RovingFocusGroup = defineComponent({ + name: 'OkuRovingFocusGroup', + components: { + OkuRovingFocusGroupImpl, + CollectionProvider, + CollectionSlot, + CollectionItemSlot, + }, + inheritAttrs: false, + props: RovingFocusGroupProps, + setup(props, { slots, attrs }) { + const forwardedRef = useForwardRef() + return () => { + const mergedProps = mergeProps(attrs, props) + return h(CollectionProvider, { + scope: props.scopeRovingFocusGroup, + }, { + default: () => h(CollectionSlot, { + scope: props.scopeRovingFocusGroup, + }, { + default: () => createVNode(OkuRovingFocusGroupImpl, { + ...mergedProps, + ref: forwardedRef, + }, slots), + }), + }) + } + }, +}) + +// TODO: https://github.com/vuejs/core/pull/7444 after delete +type _RovingFocusGroupProps = MergeProps + +const OkuRovingFocusGroup = RovingFocusGroup as typeof RovingFocusGroup & (new () => { $props: _RovingFocusGroupProps }) + +export { + OkuRovingFocusGroup, + createRovingFocusGroupScope, +} diff --git a/packages/components/roving-focus/src/RovingFocusGroupImpl.ts b/packages/components/roving-focus/src/RovingFocusGroupImpl.ts new file mode 100644 index 000000000..39395d33b --- /dev/null +++ b/packages/components/roving-focus/src/RovingFocusGroupImpl.ts @@ -0,0 +1,208 @@ +import type { ComputedRef, PropType, Ref } from 'vue' +import { computed, defineComponent, h, mergeProps, ref, toRefs, watchEffect } from 'vue' +import { useCallbackRef, useComposedRefs, useControllable, useForwardRef } from '@oku-ui/use-composable' + +import type { ComponentPublicInstanceRef, ElementType, IPrimitiveProps, InstanceTypeRef, MergeProps } from '@oku-ui/primitive' + +import { Primitive, PrimitiveProps } from '@oku-ui/primitive' +import { composeEventHandlers } from '@oku-ui/utils' +import { type Direction, type Orientation, focusFirst } from './utils' +import type { ScopedPropsInterface } from './RovingFocusGroup' +import { ScopedProps, useCollection, useRovingFocusProvider } from './RovingFocusGroup' + +const ENTRY_FOCUS = 'rovingFocusGroup.onEntryFocus' +const EVENT_OPTIONS = { bubbles: false, cancelable: true } + +export type RovingFocusGroupImplElement = ElementType<'div'> +export type _RovingFocusGroupImplEl = HTMLDivElement + +export interface RovingFocusGroupOptions extends IPrimitiveProps { + /** + * The orientation of the group. + * Mainly so arrow navigation is done accordingly (left & right vs. up & down) + */ + orientation?: Orientation + /** + * The direction of navigation between items. + */ + dir?: Direction + /** + * Whether keyboard navigation should loop around + * @defaultValue false + */ + loop?: boolean +} + +export const RovingFocusGroupOptionsProps = { + orientation: { + type: String as PropType, + }, + dir: { + type: String as PropType, + }, + loop: { + type: Boolean, + }, + ...PrimitiveProps, +} + +export interface RovingFocusGroupImplPropsType extends ScopedPropsInterface { + currentTabStopId?: Ref + defaultCurrentTabStopId?: string + onCurrentTabStopIdChange?: (tabStopId: string | null) => void + onEntryFocus?: (event: Event) => void + onMousedown?: (event: MouseEvent) => void + onFocus?: (event: FocusEvent) => void + onBlur?: (event: FocusEvent) => void + isChangedFocusableItemAdd?: number + isChangedFocusableItemRemove?: number +} + +export const RovingFocusGroupImplElementProps = { + currentTabStopId: String as unknown as PropType>, + defaultCurrentTabStopId: String, + // onCurrentTabStopIdChange: Function as PropType, + // onEntryFocus: Function as PropType, + onMousedown: Function as PropType<(e: MouseEvent) => void>, + onFocus: Function as PropType<(e: FocusEvent) => void>, + onBlur: Function as PropType<(e: FocusEvent) => void>, +} + +export const IRovingFocusGroupImplProps = { + ...RovingFocusGroupImplElementProps, + ...RovingFocusGroupOptionsProps, + ...ScopedProps, +} + +const RovingFocusGroupImpl = defineComponent({ + name: 'OkuRovingFocusGroupImpl', + inheritAttrs: false, + props: IRovingFocusGroupImplProps, + emits: { + currentTabStopId: (tabStopId: string | null) => true, + entryFocus: (event: Event) => true, + currentTabStopIdChange: (tabStopId: string | null) => true, + }, + setup(props, { attrs, slots, emit, expose }) { + const _attrs = attrs as Omit<_RovingFocusGroupImplEl, 'dir'> + const { + orientation, + loop, + dir, + currentTabStopId: currentTabStopIdProp, + defaultCurrentTabStopId, + onEntryFocus, + asChild, + ...propsData + } = toRefs(props) + + const buttonRef = ref | null>(null) + const forwardedRef = useForwardRef() + const composedRefs = useComposedRefs(buttonRef, forwardedRef) + + const { state: currentTabStopId, updateValue: updateCurrentTabStopId } = useControllable({ + prop: computed(() => currentTabStopIdProp.value), + defaultProp: computed(() => defaultCurrentTabStopId.value), + onChange: (result: any) => { + emit('currentTabStopId', result) + }, + }) + + const isTabbingBackOut = ref(false) + const handleEntryFocus = useCallbackRef(onEntryFocus?.value || undefined) + const getItems = useCollection(props.scopeRovingFocusGroup) + const isClickFocusRef = ref(false) + const focusableItemsCount = ref(0) + + watchEffect(() => { + const node = buttonRef.value?.$el + if (node) { + node.addEventListener(ENTRY_FOCUS, handleEntryFocus) + return () => node.removeEventListener(ENTRY_FOCUS, handleEntryFocus) + } + }) + + useRovingFocusProvider({ + scope: props.scopeRovingFocusGroup, + // TODO: change ref or computed + orientation: orientation.value, + // TODO: change ref or computed + dir: dir.value, + // TODO: change ref or computed + loop: loop.value ?? false, + currentTabStopId: currentTabStopId ?? null, + onItemFocus: (tabStopId: string) => { + updateCurrentTabStopId(tabStopId) + }, + onItemShiftTab: () => { + isTabbingBackOut.value = true + }, + onFocusableItemAdd: () => { + focusableItemsCount.value++ + }, + onFocusableItemRemove: () => { + focusableItemsCount.value-- + }, + }) + + const _tabIndex = computed(() => isTabbingBackOut.value || focusableItemsCount.value === 0 ? -1 : 0) + + return () => { + const merged = mergeProps(_attrs, propsData) + return h(Primitive.div, { + 'tabIndex': _tabIndex.value, + 'data-orientation': orientation.value, + ...merged, + 'ref': composedRefs, + 'style': { + outline: 'none', + ..._attrs.style as any, + }, + 'asChild': asChild.value, + 'onMousedown': composeEventHandlers(props.onMousedown, () => { + isClickFocusRef.value = true + }), + 'onFocus': composeEventHandlers(props.onFocus, (event: FocusEvent) => { + // We normally wouldn't need this check, because we already check + // that the focus is on the current target and not bubbling to it. + // We do this because Safari doesn't focus buttons when clicked, and + // instead, the wrapper will get focused and not through a bubbling event. + const isKeyboardFocus = !isClickFocusRef.value + if (event.target === event.currentTarget && isKeyboardFocus && !isTabbingBackOut.value) { + const entryFocusEvent = new CustomEvent(ENTRY_FOCUS, EVENT_OPTIONS) + event.currentTarget?.dispatchEvent(entryFocusEvent) + + if (!entryFocusEvent.defaultPrevented) { + const items = getItems.value.filter(item => item.focusable) + const activeItem = items.find(item => item.active) + const currentItem = items.find(item => item.id === currentTabStopId.value) + const candidateItems = [activeItem, currentItem, ...items].filter( + Boolean, + ) as typeof items + const candidateNodes = candidateItems.map(item => item.ref.$el!) + focusFirst(candidateNodes) + } + } + + isClickFocusRef.value = false + }), + 'onBlur': composeEventHandlers(props.onBlur, () => { + isTabbingBackOut.value = false + }), + }, { + default: () => slots.default?.(), + }) + } + }, +}) + +// TODO: https://github.com/vuejs/core/pull/7444 after delete +type _OkuRovingFocusGroupImpl = MergeProps + +export type InstanceCheckboxType = InstanceTypeRef + +const OkuRovingFocusGroupImpl = RovingFocusGroupImpl as typeof RovingFocusGroupImpl & (new () => { $props: RovingFocusGroupImplPropsType }) + +export { + OkuRovingFocusGroupImpl, +} diff --git a/packages/components/roving-focus/src/RovingFocusGroupItem.ts b/packages/components/roving-focus/src/RovingFocusGroupItem.ts new file mode 100644 index 000000000..2e4e9837c --- /dev/null +++ b/packages/components/roving-focus/src/RovingFocusGroupItem.ts @@ -0,0 +1,179 @@ +import type { PropType } from 'vue' +import { computed, defineComponent, h, mergeProps, nextTick, toRefs, watchEffect } from 'vue' +import { useForwardRef, useId } from '@oku-ui/use-composable' + +import { Primitive, PrimitiveProps } from '@oku-ui/primitive' +import type { ElementType, IPrimitiveProps, InstanceTypeRef, MergeProps } from '@oku-ui/primitive' + +import { composeEventHandlers } from '@oku-ui/utils' +import type { ItemData, ScopedPropsInterface } from './RovingFocusGroup' +import { CollectionItemSlot, ScopedProps, useCollection, useRovingFocusInject } from './RovingFocusGroup' +import { focusFirst, getFocusIntent, wrapArray } from './utils' + +export type RovingFocusGroupItemElement = ElementType<'span'> +export type _RovingFocusGroupItemEl = HTMLSpanElement + +interface IRovingFocusItemProps { + tabStopId?: string + focusable?: boolean + active?: boolean + onFocus?: (event: FocusEvent) => void + onKeydown?: (event: KeyboardEvent) => void + onMousedown?: (event: MouseEvent) => void +} + +export const RovingFocusItemProps = { + tabStopId: { + type: String, + }, + focusable: { + type: Boolean, + default: true, + }, + active: { + type: Boolean, + default: false, + }, + onFocus: Function as PropType<(event: FocusEvent) => void>, + onKeydown: Function as PropType<(event: KeyboardEvent) => void>, + onMousedown: Function as PropType<(event: MouseEvent) => void>, +} + +// Define Component Props Type +export interface RovingFocusItemPropsType extends ScopedPropsInterface, IPrimitiveProps { +} + +export const RovingFocusGroupImplElementProps = { + ...RovingFocusItemProps, +} + +export const IRovingFocusGroupImplProps = { + ...RovingFocusItemProps, + ...ScopedProps, + ...PrimitiveProps, +} + +const ITEM_NAME = 'OkuRovingFocusGroupItem' + +const RovingFocusGroupItem = defineComponent({ + name: ITEM_NAME, + components: { + CollectionItemSlot, + }, + inheritAttrs: false, + props: IRovingFocusGroupImplProps, + setup(props, { attrs, slots }) { + const _attrs = attrs as any + const { + scopeRovingFocusGroup, + focusable, + active, + tabStopId, + ...propsData + } = toRefs(props) + const attrsItems = _attrs + + const autoId = useId() + const id = computed(() => tabStopId.value ?? autoId) + const inject = useRovingFocusInject(ITEM_NAME, scopeRovingFocusGroup.value) + const isCurrentTabStop = computed(() => inject.value.currentTabStopId.value === id.value) + const getItems = useCollection(scopeRovingFocusGroup.value) + const forwardedRef = useForwardRef() + + watchEffect((onClean) => { + nextTick(() => { + if (focusable.value) + inject.value.onFocusableItemAdd() + }) + onClean(() => { + nextTick(() => { + inject.value.onFocusableItemRemove() + }) + }) + }) + + const _props: ItemData = { + id: id.value, + focusable: focusable.value, + active: active.value, + scope: scopeRovingFocusGroup.value, + } + return () => { + const merged = mergeProps(attrsItems, propsData, { + tabIndex: isCurrentTabStop.value ? 0 : -1, + }) + return h(CollectionItemSlot, { + ..._props, + }, { + default: () => { + return h(Primitive.span, { + 'tabindex': isCurrentTabStop.value ? 0 : -1, + 'data-orientation': inject.value.orientation, + ...merged, + 'ref': forwardedRef, + 'asChild': props.asChild, + 'onMousedown': + composeEventHandlers(props.onMousedown, (event: MouseEvent) => { + // We prevent focusing non-focusable items on `mousedown`. + // Even though the item has tabIndex={-1}, that only means take it out of the tab order. + if (!focusable.value) + event.preventDefault() + // Safari doesn't focus a button when clicked so we run our logic on mousedown also + else inject.value.onItemFocus(id.value) + }), + 'onFocus': composeEventHandlers(props.onFocus, () => { + inject.value.onItemFocus(id.value) + }), + 'onKeydown': composeEventHandlers(props.onKeydown, (event: KeyboardEvent) => { + if (event.key === 'Tab' && event.shiftKey) { + inject.value.onItemShiftTab() + return + } + + if (event.target !== event.currentTarget) + return + + const focusIntent = getFocusIntent(event, inject.value.orientation, inject.value.dir) + + if (focusIntent !== undefined) { + event.preventDefault() + + const items = getItems.value.filter(item => item.focusable) + let candidateNodes = items.map(item => item.ref.$el!) + + if (focusIntent === 'last') { + candidateNodes.reverse() + } + else if (focusIntent === 'prev' || focusIntent === 'next') { + if (focusIntent === 'prev') + candidateNodes.reverse() + const currentIndex = candidateNodes.indexOf(event.currentTarget as HTMLElement) + candidateNodes = inject.value.loop + ? wrapArray(candidateNodes, currentIndex + 1) + : candidateNodes.slice(currentIndex + 1) + } + + // /** + // * Imperative focus during keydown is risky so we prevent React's batching updates + // * to avoid potential bugs. See: https://github.com/facebook/react/issues/20332 + // */ + focusFirst(candidateNodes) + } + }), + }, slots) + }, + }) + } + }, +}) + +// TODO: https://github.com/vuejs/core/pull/7444 after delete +type _OkuRovingFocusGroupImpl = MergeProps + +export type InstanceCheckboxType = InstanceTypeRef + +const OkuRovingFocusGroupItem = RovingFocusGroupItem as typeof RovingFocusGroupItem & (new () => { $props: _OkuRovingFocusGroupImpl }) + +export { + OkuRovingFocusGroupItem, +} diff --git a/packages/components/roving-focus/src/index.ts b/packages/components/roving-focus/src/index.ts new file mode 100644 index 000000000..a124a06c8 --- /dev/null +++ b/packages/components/roving-focus/src/index.ts @@ -0,0 +1,2 @@ +export { OkuRovingFocusGroup } from './RovingFocusGroup' +export { OkuRovingFocusGroupItem } from './RovingFocusGroupItem' diff --git a/packages/components/roving-focus/src/roving-focus.test.ts b/packages/components/roving-focus/src/roving-focus.test.ts new file mode 100644 index 000000000..6003c219a --- /dev/null +++ b/packages/components/roving-focus/src/roving-focus.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' + +import { OkuRovingFocusGroup } from './' + +// It also works live, but the gear gives an error in the tests. + +describe('OkuRovingFocusGroup', () => { + describe('OkuRovingFocusGroupItem aschild', () => { + it('empty', () => { + const com = mount(OkuRovingFocusGroup, { + // slots: { + // default: () => [ + // h(OkuRovingFocusGroupItem, { + // asChild: true, + // }), + // h(OkuRovingFocusGroupItem, { + // asChild: true, + // }), + // ], + // }, + }) + expect(com.html()).equal(`

+ +
`) + }) + + // it('one button', async () => { + // const com = mount(OkuRovingFocusGroup, { + // slots: { + // default: () => [ + // h(OkuRovingFocusGroupItem, { + // asChild: true, + // }, { + // default: () => h('button', {}, 'button'), + // }), + // ], + // }, + // }) + // expect(com.html()).equal('
') + // }) + + // it('group and item button', () => { + // const com = mount(OkuRovingFocusGroup, { + // slots: { + // default: () => h(OkuRovingFocusGroupItem, { + // asChild: true, + // }, { + // default: () => h('button', {}, 'button'), + // }), + // }, + // }) + // expect(com.html()).equal('
') + // }) + + // it('group and item with button', () => { + // const com = mount(OkuRovingFocusGroup, { + // slots: { + // default: () => h(OkuRovingFocusGroupItem, { + // asChild: true, + // }, { + // default: () => h('button', { value: 'one' }, 'button'), + // }), + // }, + // }) + // expect(com.html()).equal('
') + // }) + + // it('group and item asChild group item two children', () => { + // const wrapper = () => mount(OkuRovingFocusGroup, { + // slots: { + // default: () => h(OkuRovingFocusGroupItem, { + // asChild: true, + // }, { + // default: () => [ + // h('button', { value: 'one' }, 'button'), + // h('button', { value: 'two' }, 'button'), + // ], + // }), + // }, + // }) + // expect(() => wrapper()).toThrowError(/can only have one child/) + // }) + + // it('group and item with button', () => { + // const com = mount(OkuRovingFocusGroup, { + // slots: { + // default: () => [ + // h(OkuRovingFocusGroupItem, { + // asChild: true, + // }, { + // default: () => h('button', { value: 'one' }, 'button'), + // }), + // h(OkuRovingFocusGroupItem, { + // asChild: true, + // }, { + // default: () => h('button', { value: 'two' }, 'button'), + // }), + // ], + // }, + // }) + // expect(com.html()).equal('
') + // }) + + // it('emty OkuRovingFocusGroup', () => { + // const com = mount(OkuRovingFocusGroup) + // expect(com.html()).equal(`
+ // + //
`) + // }) + }) + + // describe('OkuRovingFocusGroupItem', () => { + // it('empty', () => { + // const com = mount({ + // components: { + // OkuRovingFocusGroupItem, + // OkuRovingFocusGroup, + // ButtonProvide, + // ButtonComponent, + // }, + // template: ` + // + // + // + // one + // + // + // two + // + // + // three + // + // + // `, + // } as Component) + + // const wrapper = com + + // expect(wrapper.html()).equal('
') + // }) + // }) + + // describe('OkuRovingFocusGroupItem', () => { + // it('empty', () => { + // const com = mount({ + // components: { + // OkuRovingFocusGroupItem, + // OkuRovingFocusGroup, + // }, + // template: ` + // + // + // + // + // + // + // + // + // + // + // + // + // + // `, + // } as Component) + + // const data = com.html() + + // expect(data).equal('
') + // }) + // }) +}) diff --git a/packages/components/roving-focus/src/stories/Button.vue b/packages/components/roving-focus/src/stories/Button.vue new file mode 100644 index 000000000..3e7099b49 --- /dev/null +++ b/packages/components/roving-focus/src/stories/Button.vue @@ -0,0 +1,55 @@ + + + diff --git a/packages/components/roving-focus/src/stories/ButtonProvide.vue b/packages/components/roving-focus/src/stories/ButtonProvide.vue new file mode 100644 index 000000000..621e742bd --- /dev/null +++ b/packages/components/roving-focus/src/stories/ButtonProvide.vue @@ -0,0 +1,11 @@ + + + diff --git a/packages/components/roving-focus/src/stories/RovingFocusDemo.stories.ts b/packages/components/roving-focus/src/stories/RovingFocusDemo.stories.ts new file mode 100644 index 000000000..bf414e2a8 --- /dev/null +++ b/packages/components/roving-focus/src/stories/RovingFocusDemo.stories.ts @@ -0,0 +1,71 @@ +import type { Meta, StoryObj } from '@storybook/vue3' + +import type { ICheckBoxProps } from './RovingFocusDemo.vue' +import RovingFocusComponent from './RovingFocusDemo.vue' + +interface StoryProps extends ICheckBoxProps { +} + +const meta = { + title: 'Utilities/RovingFocus', + args: { + template: '#1', + }, + component: RovingFocusComponent, + tags: ['autodocs'], +} satisfies Meta & { + args: StoryProps +} + +export default meta +type Story = StoryObj & { + args: StoryProps +} + +export const Styled: Story = { + args: { + template: '#1', + allshow: true, + }, + render: (args: any) => ({ + components: { RovingFocusComponent }, + setup() { + return { args } + }, + template: ` + + `, + }), +} + +export const group: Story = { + args: { + template: '#2', + allshow: false, + }, + render: (args: any) => ({ + components: { RovingFocusComponent }, + setup() { + return { args } + }, + template: ` + + `, + }), +} + +export const more: Story = { + args: { + template: '#3', + allshow: false, + }, + render: (args: any) => ({ + components: { RovingFocusComponent }, + setup() { + return { args } + }, + template: ` + + `, + }), +} diff --git a/packages/components/roving-focus/src/stories/RovingFocusDemo.vue b/packages/components/roving-focus/src/stories/RovingFocusDemo.vue new file mode 100644 index 000000000..c638d4260 --- /dev/null +++ b/packages/components/roving-focus/src/stories/RovingFocusDemo.vue @@ -0,0 +1,135 @@ + + + diff --git a/packages/components/roving-focus/src/utils.ts b/packages/components/roving-focus/src/utils.ts new file mode 100644 index 000000000..15471909b --- /dev/null +++ b/packages/components/roving-focus/src/utils.ts @@ -0,0 +1,52 @@ +import type { AriaAttributes } from '@oku-ui/primitive' + +export type Orientation = AriaAttributes['aria-orientation'] +export type Direction = 'ltr' | 'rtl' + +export const MAP_KEY_TO_FOCUS_INTENT: Record = { + ArrowLeft: 'prev', + ArrowUp: 'prev', + ArrowRight: 'next', + ArrowDown: 'next', + PageUp: 'first', + Home: 'first', + PageDown: 'last', + End: 'last', +} + +export function getDirectionAwareKey(key: string, dir?: Direction) { + if (dir !== 'rtl') + return key + return key === 'ArrowLeft' ? 'ArrowRight' : key === 'ArrowRight' ? 'ArrowLeft' : key +} + +export type FocusIntent = 'first' | 'last' | 'prev' | 'next' + +export function getFocusIntent(event: KeyboardEvent, orientation?: Orientation, dir?: Direction) { + const key = getDirectionAwareKey(event.key, dir) + if (orientation === 'vertical' && ['ArrowLeft', 'ArrowRight'].includes(key)) + return undefined + if (orientation === 'horizontal' && ['ArrowUp', 'ArrowDown'].includes(key)) + return undefined + return MAP_KEY_TO_FOCUS_INTENT[key] +} + +export function focusFirst(candidates: HTMLElement[]) { + const PREVIOUSLY_FOCUSED_ELEMENT = document.activeElement + for (const candidate of candidates) { + // if focus is already where we want to go, we don't want to keep going through the candidates + if (candidate === PREVIOUSLY_FOCUSED_ELEMENT) + return + candidate.focus() + if (document.activeElement !== PREVIOUSLY_FOCUSED_ELEMENT) + return + } +} + +/** + * Wraps an array around itself at a given start index + * Example: `wrapArray(['a', 'b', 'c', 'd'], 2) === ['c', 'd', 'a', 'b']` + */ +export function wrapArray(array: T[], startIndex: number) { + return array.map((_, index) => array[(startIndex + index) % array.length]) +} diff --git a/packages/components/roving-focus/tsconfig.json b/packages/components/roving-focus/tsconfig.json new file mode 100644 index 000000000..b8dfa9041 --- /dev/null +++ b/packages/components/roving-focus/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "tsconfig/node16.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": [ + "src" + ] +} diff --git a/packages/components/roving-focus/tsup.config.ts b/packages/components/roving-focus/tsup.config.ts new file mode 100644 index 000000000..a2f7a0d8b --- /dev/null +++ b/packages/components/roving-focus/tsup.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'tsup' +import pkg from './package.json' + +const external = [ + ...Object.keys(pkg.dependencies || {}), + ...Object.keys(pkg.peerDependencies || {}), +] + +export default defineConfig((options) => { + return [ + { + ...options, + entryPoints: ['src/index.ts'], + external, + dts: true, + clean: true, + target: 'node16', + format: ['esm'], + outExtension: () => ({ js: '.mjs' }), + }, + ] +}) diff --git a/packages/components/separator/src/separator.ts b/packages/components/separator/src/separator.ts index 744557d46..41eb7e049 100644 --- a/packages/components/separator/src/separator.ts +++ b/packages/components/separator/src/separator.ts @@ -1,6 +1,6 @@ import type { PropType } from 'vue' import { defineComponent, h } from 'vue' -import type { ElementType, InstanceTypeRef, MergeProps, PrimitiveProps } from '@oku-ui/primitive' +import type { ElementType, IPrimitiveProps, InstanceTypeRef, MergeProps } from '@oku-ui/primitive' import { Primitive } from '@oku-ui/primitive' import { useForwardRef } from '@oku-ui/use-composable' @@ -12,7 +12,7 @@ type Orientation = typeof ORIENTATIONS[number] type SeparatorElement = ElementType<'div'> export type _SeparatorEl = HTMLDivElement -interface SeparatorProps extends PrimitiveProps { +interface SeparatorProps extends IPrimitiveProps { /** * Whether or not the component is purely decorative. When true, accessibility-related attributes * are updated so that that the rendered element is removed from the accessibility tree. @@ -77,7 +77,7 @@ const Separator = defineComponent({ }) // TODO: https://github.com/vuejs/core/pull/7444 after delete -type _SeparatorProps = MergeProps +type _SeparatorProps = MergeProps type InstanceSeparatorType = InstanceTypeRef const OkuSeparator = Separator as typeof Separator & (new () => { $props: _SeparatorProps }) diff --git a/packages/components/slot/package.json b/packages/components/slot/package.json index 876c6bfc5..f3be7ebac 100644 --- a/packages/components/slot/package.json +++ b/packages/components/slot/package.json @@ -36,7 +36,6 @@ "vue": "^3.3.0" }, "dependencies": { - "@oku-ui/primitive": "latest", "@oku-ui/use-composable": "latest", "@oku-ui/utils": "latest" }, diff --git a/packages/components/slot/src/slot.ts b/packages/components/slot/src/slot.ts index 92187ff2f..c495f09cc 100644 --- a/packages/components/slot/src/slot.ts +++ b/packages/components/slot/src/slot.ts @@ -1,4 +1,4 @@ -import { cloneVNode, createVNode, defineComponent, mergeProps } from 'vue' +import { createVNode, defineComponent, mergeProps } from 'vue' import { useComposedRefs, useForwardRef } from '@oku-ui/use-composable' import { isSlottable } from './utils' @@ -34,7 +34,7 @@ const OkuSlot = defineComponent({ }) } else if (slots.default) { - return cloneVNode(slots.default?.()[0], { ...mergeProps(attrs, props), ref: composedRefs }, true) + return createVNode(slots.default?.()[0], { ...mergeProps(attrs, props), ref: composedRefs }) } else { return null diff --git a/packages/components/slot/tsup.config.ts b/packages/components/slot/tsup.config.ts index a2f7a0d8b..6fef2d449 100644 --- a/packages/components/slot/tsup.config.ts +++ b/packages/components/slot/tsup.config.ts @@ -14,7 +14,7 @@ export default defineConfig((options) => { external, dts: true, clean: true, - target: 'node16', + target: 'esnext', format: ['esm'], outExtension: () => ({ js: '.mjs' }), }, diff --git a/packages/components/switch/src/Switch.ts b/packages/components/switch/src/Switch.ts index 96b0f98c7..9e9954a41 100644 --- a/packages/components/switch/src/Switch.ts +++ b/packages/components/switch/src/Switch.ts @@ -13,9 +13,9 @@ import { useComposedRefs, useControllable, useForwardRef } from '@oku-ui/use-com import type { ComponentPublicInstanceRef, ElementType, + IPrimitiveProps, InstanceTypeRef, MergeProps, - PrimitiveProps, } from '@oku-ui/primitive' import { Primitive } from '@oku-ui/primitive' import type { Scope } from '@oku-ui/provide' @@ -34,7 +34,7 @@ type SwitchContextValue = { disabled?: Ref } -interface SwitchProps extends PrimitiveProps { +interface SwitchProps extends IPrimitiveProps { checked?: boolean defaultChecked?: boolean required?: boolean diff --git a/packages/components/switch/src/SwitchThumb.ts b/packages/components/switch/src/SwitchThumb.ts index e5a7910d1..ae955ecb3 100644 --- a/packages/components/switch/src/SwitchThumb.ts +++ b/packages/components/switch/src/SwitchThumb.ts @@ -1,7 +1,7 @@ import type { ElementType, + IPrimitiveProps, MergeProps, - PrimitiveProps, } from '@oku-ui/primitive' import { Primitive } from '@oku-ui/primitive' import type { Scope } from '@oku-ui/provide' @@ -17,7 +17,7 @@ type SwitchThumbElement = ElementType<'span'> export type _SwitchThumbEl = HTMLSpanElement type SwitchThumbProps = SwitchThumbElement & -PrimitiveProps & { +IPrimitiveProps & { scopeSwitch?: Scope } diff --git a/packages/components/toggle/src/toggle.ts b/packages/components/toggle/src/toggle.ts index a40875280..abf99262a 100644 --- a/packages/components/toggle/src/toggle.ts +++ b/packages/components/toggle/src/toggle.ts @@ -1,7 +1,7 @@ import type { PropType } from 'vue' import { computed, defineComponent, h, toRefs, useModel } from 'vue' import { Primitive } from '@oku-ui/primitive' -import type { ElementType, InstanceTypeRef, MergeProps, PrimitiveProps } from '@oku-ui/primitive' +import type { ElementType, IPrimitiveProps, InstanceTypeRef, MergeProps } from '@oku-ui/primitive' import { composeEventHandlers } from '@oku-ui/utils' import { useControllable, useForwardRef } from '@oku-ui/use-composable' @@ -10,7 +10,7 @@ const TOGGLE_NAME = 'Toggle' type ToggleElement = ElementType<'button'> export type _ToggleEl = HTMLButtonElement -interface ToggleProps extends PrimitiveProps { +interface ToggleProps extends IPrimitiveProps { /** * The controlled state of the toggle. */ diff --git a/packages/components/visually-hidden/src/VisuallyHidden.ts b/packages/components/visually-hidden/src/VisuallyHidden.ts index c1c07b1e1..a3b877548 100644 --- a/packages/components/visually-hidden/src/VisuallyHidden.ts +++ b/packages/components/visually-hidden/src/VisuallyHidden.ts @@ -1,9 +1,9 @@ import { Primitive } from '@oku-ui/primitive' import type { ElementType, + IPrimitiveProps, InstanceTypeRef, MergeProps, - PrimitiveProps, } from '@oku-ui/primitive' import { useForwardRef } from '@oku-ui/use-composable' import type { CSSProperties } from 'vue' @@ -14,7 +14,7 @@ const NAME = 'OkuVisuallyHidden' type VisuallyHiddenElement = ElementType<'button'> export type _VisuallyHiddenEl = HTMLButtonElement -interface VisuallyHiddenProps extends PrimitiveProps {} +interface VisuallyHiddenProps extends IPrimitiveProps {} const VisuallyHidden = defineComponent({ name: NAME, diff --git a/packages/core/primitive/package.json b/packages/core/primitive/package.json index f3e666a29..60b12e139 100644 --- a/packages/core/primitive/package.json +++ b/packages/core/primitive/package.json @@ -34,7 +34,10 @@ "peerDependencies": { "vue": "^3.3.0" }, - "dependencies": {}, + "dependencies": { + "@oku-ui/slot": "latest", + "@oku-ui/use-composable": "latest" + }, "devDependencies": { "tsconfig": "workspace:^" }, diff --git a/packages/core/primitive/src/index.ts b/packages/core/primitive/src/index.ts index a3873ab58..4caee8d94 100644 --- a/packages/core/primitive/src/index.ts +++ b/packages/core/primitive/src/index.ts @@ -3,12 +3,13 @@ export type { ComponentProps, RefElement, MergeProps, - PrimitiveProps, ElementType, - PrimitivePropsWithRef, ComponentPropsWithoutRef, InstanceTypeRef, ComponentPublicInstanceRef, -} from './primitive' + IPrimitiveProps, +} from './types' + +export { PrimitiveProps, renderSlotFragments } from './utils' export type { AriaAttributes } from './types' diff --git a/packages/core/primitive/src/primitive.test.ts b/packages/core/primitive/src/primitive.test.ts index 2fad34f5a..7dd6ab598 100644 --- a/packages/core/primitive/src/primitive.test.ts +++ b/packages/core/primitive/src/primitive.test.ts @@ -1,8 +1,51 @@ import { describe, expect, it, test } from 'vitest' import { mount } from '@vue/test-utils' +import type { Component } from 'vue' +import { h } from 'vue' import { Primitive } from './index' describe('Primitive', () => { + test('asChild with button', async () => { + const example = { + components: { + Primitive, + }, + setup() { + const handleClick = () => { + expect(true).toBe(true) + } + + return () => h(Primitive.button, { asChild: true, onClick: handleClick }, { + default: () => h('button', 'Click me New'), + }) + }, + } as Component + const wrapper = mount(example) + + wrapper.find('button').trigger('click') + expect(wrapper.html()).toBe('') + }) + + test('asChild with button', async () => { + const example = { + components: { + Primitive, + }, + setup() { + const handleClick = () => { + expect(true).toBe(true) + } + + return () => h(Primitive.button, { onClick: handleClick }, { + default: () => 'Click me', + }) + }, + } as Component + const wrapper = mount(example) + wrapper.find('button').trigger('click') + expect(wrapper.html()).toBe('') + }) + it('should render div element correctly', () => { const wrapper = mount(Primitive.div) expect(wrapper.exists()).toBe(true) @@ -243,10 +286,9 @@ describe('Primitive', () => { href: 'https://example.com', }, }) - const element = wrapper.find('a') await element.trigger('click') - expect(wrapper.emitted('click')).toBeTruthy() + expect(wrapper.emitted()).toBeTruthy() }) test('asChild prop', () => { @@ -258,7 +300,6 @@ describe('Primitive', () => { default: 'Hello', }, }) - expect(wrapper.html()).toBe('Hello') }) @@ -308,7 +349,7 @@ describe('Primitive', () => { }, }) - expect(() => wrapper()).toThrowError(/Detected an invalid children/) + expect(() => wrapper()).toThrowError(/can only have one child/) }) test('asChild with 2 children and attrs', () => { @@ -329,7 +370,25 @@ describe('Primitive', () => { }, }) - expect(() => wrapper()).toThrowError(/Detected an invalid children/) + expect(() => wrapper()).toThrowError(/can only have one child/) + }) + + test('asChild with default 3 children', () => { + const wrapper = () => mount(Primitive.div, { + props: { + asChild: true, + disabled: true, + disabled2: true, + }, + slots: { + default: ` +
Oku
+ Oku + Oku + `, + }, + }) + expect(() => wrapper()).toThrowError(/can only have one child/) }) test('asChild with default 3 children', () => { @@ -347,6 +406,6 @@ describe('Primitive', () => { `, }, }) - expect(() => wrapper()).toThrowError(/Detected an invalid children/) + expect(() => wrapper()).toThrowError(/can only have one child/) }) }) diff --git a/packages/core/primitive/src/primitive.ts b/packages/core/primitive/src/primitive.ts index 8820b8f3b..3a195ed2f 100644 --- a/packages/core/primitive/src/primitive.ts +++ b/packages/core/primitive/src/primitive.ts @@ -1,119 +1,15 @@ // same inspiration and resource https://github.com/chakra-ui/ark/blob/main/packages/vue/src/factory.tsx -import type { - ComponentPropsOptions, - ComponentPublicInstance, - DefineComponent, - FunctionalComponent, - HTMLAttributes, - IntrinsicElementAttributes, -} from 'vue' import { - cloneVNode, + createVNode, defineComponent, - getCurrentInstance, - h, mergeProps, onMounted, } from 'vue' -import { isValidVNodeElement, renderSlotFragments } from './utils' -import type { AriaAttributes } from './types' - -const NODES = [ - 'a', - 'button', - 'div', - 'form', - 'h2', - 'h3', - 'img', - 'input', - 'label', - 'li', - 'nav', - 'ol', - 'p', - 'span', - 'svg', - 'ul', -] as const - -interface NodeElementTagNameMap { - a: HTMLAnchorElement - button: HTMLButtonElement - div: HTMLDivElement - form: HTMLFormElement - h2: HTMLHeadingElement - h3: HTMLHeadingElement - img: HTMLImageElement - input: HTMLInputElement - label: HTMLLabelElement - li: HTMLLIElement - nav: HTMLElement - ol: HTMLOListElement - p: HTMLParagraphElement - span: HTMLSpanElement - svg: SVGSVGElement - ul: HTMLUListElement -} - -type ElementConstructor

= - | (new () => { $props: P }) - | ((props: P, ...args: any) => FunctionalComponent) - -// extends keyof JSX.IntrinsicElements | ElementConstructor -type ComponentProps< - T extends keyof JSX.IntrinsicElements | ElementConstructor, -> = T extends ElementConstructor - ? P - : T extends keyof JSX.IntrinsicElements - ? JSX.IntrinsicElements[T] - : Record - -type RefElement any> = Omit< - InstanceType, - keyof ComponentPublicInstance | 'class' | 'style' -> - -type InstanceTypeRef any, T> = Omit, '$el'> & { - $el: T -} - -type ComponentPublicInstanceRef = Omit & { - $el: T -} -type MergeProps = U & T - -interface PrimitiveProps { - asChild?: boolean -} - -type PrimitivePropsWithRef = - HTMLAttributes & - ComponentPropsOptions & { - asChild?: boolean - } - -type PropsWithoutRef

= P extends any - ? 'ref' extends keyof P - ? Pick> - : P - : P - -type ComponentPropsWithoutRef< - T extends keyof HTMLElementTagNameMap | DefineComponent, -> = PropsWithoutRef> - -type Primitives = { - [E in (typeof NODES)[number]]: DefineComponent<{ - asChild?: boolean - } & IntrinsicElementAttributes[E]> & NodeElementTagNameMap[E] & AriaAttributes -} - -type ElementType = Partial< - IntrinsicElementAttributes[T] -> +// import { useForwardRef } from '@oku-ui/use-composable' +import { OkuSlot } from '@oku-ui/slot' +import { NODES, type Primitives } from './types' const Primitive = NODES.reduce((primitive, node) => { const Node = defineComponent({ @@ -123,92 +19,25 @@ const Primitive = NODES.reduce((primitive, node) => { asChild: Boolean, }, setup(props, { attrs, slots }) { - const instance = getCurrentInstance() + // TODO: add support for ref forwarding + // const forwardedRef = useForwardRef() + const { asChild, ...primitiveProps } = props onMounted(() => { (window as any)[Symbol.for('oku-ui')] = true }) - const Tag: any = props.asChild ? 'slot' : node - if (!props.asChild) { - return () => - h( - Tag, - { ...attrs }, - { - default: () => slots.default && slots.default(), - }, - ) - } - else { - return () => { - let children = slots.default?.() - children = renderSlotFragments(children || []) + const Tag: any = asChild ? OkuSlot : node + return () => { + const defaultSlot = slots.default?.() - if (Object.keys(attrs).length > 0) { - const [firstChild, ...otherChildren] = children - if (!isValidVNodeElement(firstChild) || otherChildren.length > 0) { - const componentName = instance?.parent?.type.name - ? `<${instance.parent.type.name} />` - : 'component' - throw new Error( - [ - `Detected an invalid children for \`${componentName}\` with \`asChild\` prop.`, - '', - 'Note: All components accepting `asChild` expect only one direct child of valid VNode type.', - 'You can apply a few solutions:', - [ - 'Provide a single child element so that we can forward the props onto that element.', - 'Ensure the first child is an actual element instead of a raw text node or comment node.', - ] - .map(line => ` - ${line}`) - .join('\n'), - ].join('\n'), - ) - } + if (asChild && defaultSlot?.length && defaultSlot?.length > 1) + throw new Error(`The ${node} component can only have one child`) - const mergedProps = mergeProps(firstChild.props ?? {}, attrs) - const cloned = cloneVNode(firstChild, mergedProps) - // Explicitly override props starting with `on`. - // It seems cloneVNode from Vue doesn't like overriding `onXXX` props. So - // we have to do it manually. - for (const prop in mergedProps) { - if (prop.startsWith('on')) { - cloned.props ||= {} - cloned.props[prop] = mergedProps[prop] - } - } - return cloned - } - else if (Array.isArray(children)) { - if (children.length === 1) { - return children[0] - } - else { - const componentName = instance?.parent?.type.name - ? `<${instance.parent.type.name} />` - : 'component' - throw new Error( - [ - `Detected an invalid children for \`${componentName}\` with \`asChild\` prop.`, - '', - 'Note: All components accepting `asChild` expect only one direct child of valid VNode type.', - 'You can apply a few solutions:', - [ - 'Provide a single child element so that we can forward the props onto that element.', - 'Ensure the first child is an actual element instead of a raw text node or comment node.', - ] - .map(line => ` - ${line}`) - .join('\n'), - ].join('\n'), - ) - } - } - else { - // No children. - return null - } - } + const mergedProps = mergeProps(attrs, primitiveProps) + return createVNode(Tag, { ...mergedProps }, { + default: () => slots.default?.(), + }) } }, }) @@ -219,14 +48,3 @@ const Primitive = NODES.reduce((primitive, node) => { const OkuPrimitive = Primitive export { OkuPrimitive, Primitive } -export type { - ComponentProps, - MergeProps, - PrimitiveProps, - RefElement, - ElementType, - PrimitivePropsWithRef, - ComponentPropsWithoutRef, - InstanceTypeRef, - ComponentPublicInstanceRef, -} diff --git a/packages/core/primitive/src/types.ts b/packages/core/primitive/src/types.ts index 1cf1068c7..e2ecba0dd 100644 --- a/packages/core/primitive/src/types.ts +++ b/packages/core/primitive/src/types.ts @@ -1,3 +1,101 @@ +import type { + ComponentPropsOptions, + ComponentPublicInstance, + DefineComponent, + FunctionalComponent, + IntrinsicElementAttributes, +} from 'vue' + +export const NODES = [ + 'a', + 'button', + 'div', + 'form', + 'h2', + 'h3', + 'img', + 'input', + 'label', + 'li', + 'nav', + 'ol', + 'p', + 'span', + 'svg', + 'ul', +] as const + +export interface NodeElementTagNameMap { + a: HTMLAnchorElement + button: HTMLButtonElement + div: HTMLDivElement + form: HTMLFormElement + h2: HTMLHeadingElement + h3: HTMLHeadingElement + img: HTMLImageElement + input: HTMLInputElement + label: HTMLLabelElement + li: HTMLLIElement + nav: HTMLElement + ol: HTMLOListElement + p: HTMLParagraphElement + span: HTMLSpanElement + svg: SVGSVGElement + ul: HTMLUListElement +} + +export type ElementConstructor

= + | (new () => { $props: P }) + | ((props: P, ...args: any) => FunctionalComponent) + +// extends keyof JSX.IntrinsicElements | ElementConstructor +export type ComponentProps< + T extends keyof JSX.IntrinsicElements | ElementConstructor, +> = T extends ElementConstructor + ? P + : T extends keyof JSX.IntrinsicElements + ? JSX.IntrinsicElements[T] + : Record + +export type RefElement any> = Omit< + InstanceType, + keyof ComponentPublicInstance | 'class' | 'style' +> + +export type InstanceTypeRef any, T> = Omit, '$el'> & { + $el: T +} + +export type ComponentPublicInstanceRef = Omit & { + $el: T +} + +export type MergeProps = U & T + +export interface IPrimitiveProps { + asChild?: boolean +} + +export type PropsWithoutRef

= P extends any + ? 'ref' extends keyof P + ? Pick> + : P + : P + +export type ComponentPropsWithoutRef< + T extends keyof HTMLElementTagNameMap | DefineComponent, +> = PropsWithoutRef> + +export type Primitives = { + [E in (typeof NODES)[number]]: DefineComponent<{ + asChild?: boolean + } & IntrinsicElementAttributes[E]> & NodeElementTagNameMap[E] & AriaAttributes +} + +export type ElementType = Partial< + IntrinsicElementAttributes[T] +> + /** * Wraps an array around itself at a given start index * Example: `wrapArray(['a', 'b', 'c', 'd'], 2) === ['c', 'd', 'a', 'b']` diff --git a/packages/core/primitive/src/utils.ts b/packages/core/primitive/src/utils.ts index 618c5c3ea..fc1555c18 100644 --- a/packages/core/primitive/src/utils.ts +++ b/packages/core/primitive/src/utils.ts @@ -42,3 +42,7 @@ export function renderSlotFragments(children: VNode[]): VNode[] { return [child] }) } + +export const PrimitiveProps = { + asChild: Boolean, +} diff --git a/packages/core/provide/src/createProvide.ts b/packages/core/provide/src/createProvide.ts index cf53234ae..9c98347f0 100644 --- a/packages/core/provide/src/createProvide.ts +++ b/packages/core/provide/src/createProvide.ts @@ -69,7 +69,6 @@ function createProvideScope(scopeName: string, createProvideScopeDeps: CreateSco ) { const { scope, ...context } = props as any const Provide = scope?.[scopeName][index] || BaseScope.key as ProvideValueType - const value = computed(() => context) provide(Provide, value) } diff --git a/packages/core/use-composable/src/index.ts b/packages/core/use-composable/src/index.ts index c65bcb82c..3a9b04522 100644 --- a/packages/core/use-composable/src/index.ts +++ b/packages/core/use-composable/src/index.ts @@ -1,4 +1,4 @@ -import { computedEager, syncRef } from '@vueuse/core' +import { computedAsync, computedEager, syncRef } from '@vueuse/core' export { useControllable } from './useControllable' export { useCallbackRef } from './useCallbackRef' @@ -10,5 +10,4 @@ export { useComposedRefs } from './useComposedRefs' export { useForwardRef } from './useForwardRef' export { useEscapeKeydown } from './useEscapeKeydown' export type { MaybeComputedElementRef } from './unrefElement' - -export { computedEager, syncRef } +export { computedEager, syncRef, computedAsync } diff --git a/playground/nuxt3/package.json b/playground/nuxt3/package.json index 5d2a26bb7..9fc8e7936 100644 --- a/playground/nuxt3/package.json +++ b/playground/nuxt3/package.json @@ -18,6 +18,7 @@ "@oku-ui/collection": "workspace:^", "@oku-ui/label": "workspace:^", "@oku-ui/progress": "workspace:^", + "@oku-ui/roving-focus": "workspace:^", "@oku-ui/separator": "workspace:^", "@oku-ui/slot": "workspace:^", "@oku-ui/switch": "workspace:^", diff --git a/playground/nuxt3/pages/index.vue b/playground/nuxt3/pages/index.vue index 0af25748c..fb7139b86 100644 --- a/playground/nuxt3/pages/index.vue +++ b/playground/nuxt3/pages/index.vue @@ -52,6 +52,10 @@ const pages: Page[] = [ name: 'OkuSlot', path: '/slot', }, + { + name: 'roving-focus', + path: '/roving-focus', + }, ] diff --git a/playground/nuxt3/pages/roving-focus.vue b/playground/nuxt3/pages/roving-focus.vue new file mode 100644 index 000000000..5e9035436 --- /dev/null +++ b/playground/nuxt3/pages/roving-focus.vue @@ -0,0 +1,5 @@ + diff --git a/playground/vue3/package.json b/playground/vue3/package.json index eddd456b1..0e137c724 100644 --- a/playground/vue3/package.json +++ b/playground/vue3/package.json @@ -16,7 +16,9 @@ "@oku-ui/checkbox": "workspace:^", "@oku-ui/label": "workspace:^", "@oku-ui/progress": "workspace:^", + "@oku-ui/roving-focus": "workspace:^", "@oku-ui/separator": "workspace:^", + "@oku-ui/slot": "workspace:^", "@oku-ui/switch": "workspace:^", "vite-plugin-pages": "^0.31.0", "vue": "^3.3.4", diff --git a/playground/vue3/src/pages/index.vue b/playground/vue3/src/pages/index.vue index ba47fffbc..9231170b9 100644 --- a/playground/vue3/src/pages/index.vue +++ b/playground/vue3/src/pages/index.vue @@ -44,6 +44,14 @@ const pages: Page[] = [ name: 'OkuSwitch', path: '/switch', }, + { + name: 'OkuSlot', + path: '/slot', + }, + { + name: 'roving-focus', + path: '/roving-focus', + }, ] diff --git a/playground/vue3/src/pages/roving-focus.vue b/playground/vue3/src/pages/roving-focus.vue new file mode 100644 index 000000000..5e9035436 --- /dev/null +++ b/playground/vue3/src/pages/roving-focus.vue @@ -0,0 +1,5 @@ + diff --git a/playground/vue3/src/pages/slot.vue b/playground/vue3/src/pages/slot.vue new file mode 100644 index 000000000..a89012ae7 --- /dev/null +++ b/playground/vue3/src/pages/slot.vue @@ -0,0 +1,5 @@ + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5daa60bce..59e5eee7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,6 +55,9 @@ importers: '@oku-ui/provide': specifier: workspace:^ version: link:packages/core/provide + '@oku-ui/roving-focus': + specifier: workspace:^ + version: link:packages/components/roving-focus '@oku-ui/separator': specifier: workspace:^ version: link:packages/components/separator @@ -418,11 +421,20 @@ importers: specifier: workspace:^ version: link:../../tsconfig - packages/components/separator: + packages/components/roving-focus: dependencies: + '@oku-ui/collection': + specifier: latest + version: link:../collection + '@oku-ui/direction': + specifier: latest + version: link:../direction '@oku-ui/primitive': specifier: latest version: link:../../core/primitive + '@oku-ui/provide': + specifier: latest + version: link:../../core/provide '@oku-ui/use-composable': specifier: latest version: link:../../core/use-composable @@ -437,7 +449,7 @@ importers: specifier: workspace:^ version: link:../../tsconfig - packages/components/slot: + packages/components/separator: dependencies: '@oku-ui/primitive': specifier: latest @@ -456,6 +468,22 @@ importers: specifier: workspace:^ version: link:../../tsconfig + packages/components/slot: + dependencies: + '@oku-ui/use-composable': + specifier: latest + version: link:../../core/use-composable + '@oku-ui/utils': + specifier: latest + version: link:../../core/utils + vue: + specifier: ^3.3.0 + version: 3.3.4 + devDependencies: + tsconfig: + specifier: workspace:^ + version: link:../../tsconfig + packages/components/switch: dependencies: '@oku-ui/primitive': @@ -518,6 +546,12 @@ importers: packages/core/primitive: dependencies: + '@oku-ui/slot': + specifier: latest + version: link:../../components/slot + '@oku-ui/use-composable': + specifier: latest + version: link:../use-composable vue: specifier: ^3.3.0 version: 3.3.4 @@ -602,6 +636,9 @@ importers: '@oku-ui/progress': specifier: workspace:^ version: link:../../packages/components/progress + '@oku-ui/roving-focus': + specifier: workspace:^ + version: link:../../packages/components/roving-focus '@oku-ui/separator': specifier: workspace:^ version: link:../../packages/components/separator @@ -642,9 +679,15 @@ importers: '@oku-ui/progress': specifier: workspace:^ version: link:../../packages/components/progress + '@oku-ui/roving-focus': + specifier: workspace:^ + version: link:../../packages/components/roving-focus '@oku-ui/separator': specifier: workspace:^ version: link:../../packages/components/separator + '@oku-ui/slot': + specifier: workspace:^ + version: link:../../packages/components/slot '@oku-ui/switch': specifier: workspace:^ version: link:../../packages/components/switch @@ -933,7 +976,7 @@ packages: '@babel/helper-plugin-utils': 7.22.5 debug: 4.3.4 lodash.debounce: 4.0.8 - resolve: 1.22.2 + resolve: 1.22.3 transitivePeerDependencies: - supports-color dev: true @@ -3089,7 +3132,7 @@ packages: '@rushstack/ts-command-line': 4.15.1 colors: 1.2.5 lodash: 4.17.21 - resolve: 1.22.2 + resolve: 1.22.3 semver: 7.5.4 source-map: 0.6.1 typescript: 5.0.4 @@ -3904,7 +3947,7 @@ packages: deepmerge: 4.3.1 is-builtin-module: 3.2.1 is-module: 1.0.0 - resolve: 1.22.2 + resolve: 1.22.3 rollup: 3.21.0 dev: true @@ -3922,7 +3965,7 @@ packages: deepmerge: 4.3.1 is-builtin-module: 3.2.1 is-module: 1.0.0 - resolve: 1.22.2 + resolve: 1.22.3 rollup: 3.26.2 dev: true @@ -4032,7 +4075,7 @@ packages: fs-extra: 7.0.1 import-lazy: 4.0.0 jju: 1.4.0 - resolve: 1.22.2 + resolve: 1.22.3 semver: 7.5.4 z-schema: 5.0.5 dev: true @@ -4040,7 +4083,7 @@ packages: /@rushstack/rig-package@0.4.0: resolution: {integrity: sha512-FnM1TQLJYwSiurP6aYSnansprK5l8WUK8VG38CmAaZs29ZeL1msjK0AP1VS4ejD33G0kE/2cpsPsS9jDenBMxw==} dependencies: - resolve: 1.22.2 + resolve: 1.22.3 strip-json-comments: 3.1.1 dev: true @@ -8015,7 +8058,7 @@ packages: dependencies: debug: 3.2.7 is-core-module: 2.12.1 - resolve: 1.22.2 + resolve: 1.22.3 transitivePeerDependencies: - supports-color dev: false @@ -10855,7 +10898,7 @@ packages: resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} dependencies: hosted-git-info: 2.8.9 - resolve: 1.22.2 + resolve: 1.22.3 semver: 5.7.1 validate-npm-package-license: 3.0.4 @@ -11536,7 +11579,7 @@ packages: postcss: 8.4.27 postcss-value-parser: 4.2.0 read-cache: 1.0.0 - resolve: 1.22.2 + resolve: 1.22.3 dev: true /postcss-import@15.1.0(postcss@8.4.27): @@ -11548,7 +11591,7 @@ packages: postcss: 8.4.27 postcss-value-parser: 4.2.0 read-cache: 1.0.0 - resolve: 1.22.2 + resolve: 1.22.3 dev: true /postcss-js@4.0.1(postcss@8.4.27): @@ -12041,7 +12084,7 @@ packages: jstransformer: 1.0.0 pug-error: 2.0.0 pug-walk: 2.0.0 - resolve: 1.22.2 + resolve: 1.22.3 dev: true /pug-lexer@5.0.1: @@ -12542,7 +12585,6 @@ packages: is-core-module: 2.12.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - dev: false /restore-cursor@3.1.0: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} diff --git a/tsconfig.json b/tsconfig.json index 4c73dcdd6..1161f68c4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,7 @@ "isolatedModules": true, "strict": true, "jsx": "preserve", + "jsxImportSource": "vue", "sourceMap": true, "resolveJsonModule": true, "esModuleInterop": true,