diff --git a/package.json b/package.json index 5fe79ce78..23b435e23 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,8 @@ "@oku-ui/collapsible": "workspace:^", "@oku-ui/collection": "workspace:^", "@oku-ui/direction": "workspace:^", - "@oku-ui/focus-group": "workspace:^", + "@oku-ui/dismissable-layer": "workspace:^", + "@oku-ui/focus-guards": "workspace:^", "@oku-ui/focus-scope": "workspace:^", "@oku-ui/label": "workspace:^", "@oku-ui/popper": "workspace:^", diff --git a/packages/components/dismissable-layer/README.md b/packages/components/dismissable-layer/README.md new file mode 100644 index 000000000..2cbbc4957 --- /dev/null +++ b/packages/components/dismissable-layer/README.md @@ -0,0 +1,10 @@ +# `@oku-ui/dismissable-layer` + +## Installation + +```sh +$ pnpm add @oku-ui/dismissable-layer +``` + +## Usage +... diff --git a/packages/core/focus-group/build.config.ts b/packages/components/dismissable-layer/build.config.ts similarity index 100% rename from packages/core/focus-group/build.config.ts rename to packages/components/dismissable-layer/build.config.ts diff --git a/packages/components/dismissable-layer/package.json b/packages/components/dismissable-layer/package.json new file mode 100644 index 000000000..bbb946698 --- /dev/null +++ b/packages/components/dismissable-layer/package.json @@ -0,0 +1,50 @@ +{ + "name": "@oku-ui/dismissable-layer", + "type": "module", + "version": "0.0.0", + "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/dismissable-layer" + }, + "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/primitive": "latest", + "@oku-ui/provide": "latest", + "@oku-ui/use-composable": "latest", + "@oku-ui/utils": "latest" + }, + "devDependencies": { + "tsconfig": "workspace:^" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/components/dismissable-layer/src/DismissableLayer.test.ts b/packages/components/dismissable-layer/src/DismissableLayer.test.ts new file mode 100644 index 000000000..b07c4ac85 --- /dev/null +++ b/packages/components/dismissable-layer/src/DismissableLayer.test.ts @@ -0,0 +1,37 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { OkuDismissableLayer } from './DismissableLayer' + +// skipping this test for now. There's an error "TypeError: Cannot read properties of undefined (reading 'devtoolsRawSetupState')" +describe.skip('DismissableLayer', () => { + it('renders the component correctly', () => { + const wrapper = mount(OkuDismissableLayer) + + // You can add more specific assertions based on your component's structure and props + expect(wrapper.exists()).toBe(true) + expect(wrapper.find('div').exists()).toBe(true) + }) + + it('emits events when interactions happen outside the layer', async () => { + const wrapper = mount(OkuDismissableLayer) + + // Simulate a click outside the layer + await wrapper.trigger('pointerdown', { target: document.body }) + + // Check if the emitted events are correct + expect(wrapper.emitted('pointerDownOutside')).toHaveLength(1) + expect(wrapper.emitted('interactOutside')).toHaveLength(1) + expect(wrapper.emitted('dismiss')).toHaveLength(1) + }) + + it('emits events when Escape key is pressed', async () => { + const wrapper = mount(OkuDismissableLayer) + + // Simulate pressing the Escape key + await wrapper.trigger('keydown', { key: 'Escape' }) + + // Check if the emitted events are correct + expect(wrapper.emitted('escapeKeyDown')).toHaveLength(1) + expect(wrapper.emitted('dismiss')).toHaveLength(1) + }) +}) diff --git a/packages/components/dismissable-layer/src/DismissableLayer.ts b/packages/components/dismissable-layer/src/DismissableLayer.ts new file mode 100644 index 000000000..8e4692c0b --- /dev/null +++ b/packages/components/dismissable-layer/src/DismissableLayer.ts @@ -0,0 +1,341 @@ +import { + useComposedRefs, + useEscapeKeydown, + useForwardRef, +} from '@oku-ui/use-composable' +import type { ElementType, PrimitiveProps } from '@oku-ui/primitive' +import { Primitive, primitiveProps } from '@oku-ui/primitive' +import type { Ref } from 'vue' +import { + computed, + defineComponent, + h, + provide, + ref, + toRefs, + watchEffect, +} from 'vue' +import { composeEventHandlers } from '@oku-ui/utils' +import type { ScopeDismissableLayer } from './util' +import { + dispatchUpdate, + scopeDismissableLayerProps, + useFocusOutside, + usePointerDownOutside, +} from './util' + +/* ------------------------------------------------------------------------------------------------- + * DismissableLayer + * ----------------------------------------------------------------------------------------------- */ +export const INJECT_UPDATE = 'dismissableLayer.update' +export const POINTER_DOWN_OUTSIDE = 'dismissableLayer.pointerDownOutside' +export const FOCUS_OUTSIDE = 'dismissableLayer.focusOutside' + +let originalBodyPointerEvents: string + +export const DISMISSABLE_NAME = 'OkuDismissableLayer' +export const DismissableLayerProvideKey = Symbol('DismissableLayerProvide') + +export type DismissableLayerIntrinsicElement = ElementType<'div'> +export type DismissableLayerElement = HTMLDivElement + +export type DismissableLayerProvideValue = { + layers: Ref> + layersWithOutsidePointerEventsDisabled: Ref> + branches: Ref> +} + +export type PointerDownOutsideEvent = CustomEvent<{ + originalEvent: PointerEvent +}> +export type FocusOutsideEvent = CustomEvent<{ originalEvent: FocusEvent }> +export type FocusCaptureEvent = CustomEvent<{ originalEvent: FocusEvent }> +export type FocusBlurCaptureEvent = CustomEvent<{ originalEvent: FocusEvent }> +export type PointerDownCaptureEvent = CustomEvent<{ + originalEvent: PointerEvent +}> + +interface DismissableLayerProps extends PrimitiveProps { + /** + * When `true`, hover/focus/click interactions will be disabled on elements outside + * the `DismissableLayer`. Users will need to click twice on outside elements to + * interact with them: once to close the `DismissableLayer`, and again to trigger the element. + */ + disableOutsidePointerEvents?: boolean + /** + * Event handler called when the escape key is down. + * Can be prevented. + */ + onEscapeKeyDown?: (event: KeyboardEvent) => void + /** + * Event handler called when the a `pointerdown` event happens outside of the `DismissableLayer`. + * Can be prevented. + */ + onPointerDownOutside?: (event: PointerDownOutsideEvent) => void + /** + * Event handler called when the focus moves outside of the `DismissableLayer`. + * Can be prevented. + */ + onFocusOutside?: (event: FocusOutsideEvent) => void + /** + * Event handler called when an interaction happens outside the `DismissableLayer`. + * Specifically, when a `pointerdown` event happens outside or focus moves outside of it. + * Can be prevented. + */ + onInteractOutside?: ( + event: PointerDownOutsideEvent | FocusOutsideEvent + ) => void + /** + * Handler called when the `DismissableLayer` should be dismissed + */ + onDismiss?: () => void + + onFocusCapture?: (event: FocusCaptureEvent) => void + + onBlurCapture?: (event: FocusBlurCaptureEvent) => void + + onPointerDownCapture?: (event: PointerDownCaptureEvent) => void +} + +const dismissableLayerProps = { + disableOutsidePointerEvents: { + type: Boolean, + default: false, + }, +} + +const DismissableLayer = defineComponent({ + name: DISMISSABLE_NAME, + inheritAttrs: false, + props: { + ...dismissableLayerProps, + ...primitiveProps, + ...scopeDismissableLayerProps, + }, + emits: { + /** + * Event handler called when the escape key is down. + * Can be prevented. + */ + escapeKeyDown: (event: KeyboardEvent) => true, + /** + * Event handler called when an interaction happens outside the `DismissableLayer`. + * Specifically, when a `pointerdown` event happens outside or focus moves outside of it. + * Can be prevented. + */ + interactOutside: (event: PointerDownOutsideEvent | FocusOutsideEvent) => + true, + /** + * Event handler called when the a `pointerdown` event happens outside of the `DismissableLayer`. + * Can be prevented. + */ + pointerDownOutside: (event: PointerDownOutsideEvent) => true, + /** + * Event handler called when the focus moves outside of the `DismissableLayer`. + * Can be prevented. + */ + focusOutside: (event: FocusOutsideEvent) => true, + /** + * Handler called when the `DismissableLayer` should be dismissed + */ + dismiss: () => true, + focusCapture: (event: FocusCaptureEvent) => true, + blurCapture: (event: FocusBlurCaptureEvent) => true, + pointerDownCapture: (event: PointerDownCaptureEvent) => true, + }, + setup(props, { attrs, emit, slots }) { + const { disableOutsidePointerEvents } = toRefs(props) + + const { ...dismissableLayerAttrs } = attrs + + const _layers = ref(new Set()) + + const layersWithOutsidePointerEventsDisabled = ref( + new Set(), + ) + + const branches = ref(new Set()) + + const layers = computed(() => Array.from(_layers.value)) + + provide(DismissableLayerProvideKey, { + layers: _layers, + layersWithOutsidePointerEventsDisabled, + branches, + }) + + const node = ref(null) + + const forwardedRef = useForwardRef() + const composedRefs = useComposedRefs(node, forwardedRef) + + const ownerDocument = computed( + () => node.value?.ownerDocument ?? globalThis?.document, + ) + + const highestLayerWithOutsidePointerEventsDisabled = computed(() => { + const [highestLayerWithOutsidePointerEventsDisabled] = [ + ...layersWithOutsidePointerEventsDisabled.value, + ].slice(-1) + + return highestLayerWithOutsidePointerEventsDisabled + }) + + const highestLayerWithOutsidePointerEventsDisabledIndex = computed(() => + layers.value.indexOf(highestLayerWithOutsidePointerEventsDisabled.value), + ) + + const index = computed(() => { + return node.value ? layers.value.indexOf(node.value) : -1 + }) + + const isBodyPointerEventsDisabled = computed( + () => layersWithOutsidePointerEventsDisabled.value.size > 0, + ) + + const isPointerEventsEnabled = computed(() => { + return ( + index.value >= highestLayerWithOutsidePointerEventsDisabledIndex.value + ) + }) + + const pointerDownOutside = usePointerDownOutside((event) => { + const target = event.target as HTMLElement + const isPointerDownOnBranch = [...branches.value].some(branch => + branch.contains(target), + ) + + if (!isPointerEventsEnabled.value || isPointerDownOnBranch) + return + + emit('pointerDownOutside', event) + emit('interactOutside', event) + + if (!event.defaultPrevented) + emit('dismiss') + }, ownerDocument.value) + + const focusOutside = useFocusOutside((event) => { + const target = event.target as HTMLElement + const isFocusInBranch = [...branches.value].some(branch => + branch.contains(target), + ) + + if (isFocusInBranch) + return + + emit('focusOutside', event) + emit('interactOutside', event) + + if (!event.defaultPrevented) + emit('dismiss') + }, ownerDocument.value) + + useEscapeKeydown((event) => { + const isHighestLayer = index.value === _layers.value.size - 1 + + if (!isHighestLayer) + return + + emit('escapeKeyDown', event) + + if (!event.defaultPrevented) + emit('dismiss') + }, ownerDocument.value) + + watchEffect((onInvalidate) => { + if (!node.value) + return + + if (disableOutsidePointerEvents.value) { + if (layersWithOutsidePointerEventsDisabled.value.size === 0) { + originalBodyPointerEvents + = ownerDocument.value.body.style.pointerEvents + ownerDocument.value.body.style.pointerEvents = 'none' + } + layersWithOutsidePointerEventsDisabled.value.add(node.value) + } + + _layers.value.add(node.value) + + dispatchUpdate() + + onInvalidate(() => { + if ( + disableOutsidePointerEvents.value + && layersWithOutsidePointerEventsDisabled.value.size === 1 + ) { + ownerDocument.value.body.style.pointerEvents + = originalBodyPointerEvents + } + }) + }) + + /** + * We purposefully prevent combining this effect with the `disableOutsidePointerEvents` effect + * because a change to `disableOutsidePointerEvents` would remove this layer from the stack + * and add it to the end again so the layering order wouldn't be _creation order_. + * We only want them to be removed from inject stacks when unmounted. + */ + watchEffect((onInvalidate) => { + onInvalidate(() => { + if (!node.value) + return + _layers.value.delete(node.value) + layersWithOutsidePointerEventsDisabled.value.delete(node.value) + dispatchUpdate() + }) + }) + + watchEffect((onInvalidate) => { + const handleUpdate = () => {} + document.addEventListener(INJECT_UPDATE, handleUpdate) + + onInvalidate(() => + document.removeEventListener(INJECT_UPDATE, handleUpdate), + ) + }) + + const originalReturn = () => + h( + Primitive.div, + { + ...dismissableLayerAttrs, + ref: composedRefs, + asChild: props.asChild, + style: { + pointerEvents: isBodyPointerEventsDisabled.value + ? isPointerEventsEnabled.value + ? 'auto' + : 'none' + : undefined, + ...(dismissableLayerAttrs.style as CSSPropertyRule), + }, + onFocusCapture: composeEventHandlers((e) => { + emit('focusCapture', e) + }, focusOutside.onFocusCapture), + onBlurCapture: composeEventHandlers((e) => { + emit('blurCapture', e) + }, focusOutside.onBlurCapture), + onPointerDownCapture: composeEventHandlers( + (e) => { + emit('pointerDownCapture', e) + }, + pointerDownOutside.onPointerDownCapture, + ), + }, + { + default: slots.default?.(), + }, + ) + + return originalReturn + }, +}) + +export const OkuDismissableLayer = DismissableLayer as typeof DismissableLayer & +(new () => { + $props: ScopeDismissableLayer> +}) + +export type { DismissableLayerProps } diff --git a/packages/components/dismissable-layer/src/DismissableLayerBranch.ts b/packages/components/dismissable-layer/src/DismissableLayerBranch.ts new file mode 100644 index 000000000..e58bef14f --- /dev/null +++ b/packages/components/dismissable-layer/src/DismissableLayerBranch.ts @@ -0,0 +1,64 @@ +import { Primitive, primitiveProps } from '@oku-ui/primitive' +import type { + ElementType, + PrimitiveProps, +} from '@oku-ui/primitive' +import { defineComponent, h, inject, ref, watchEffect } from 'vue' +import { useComposedRefs, useForwardRef } from '@oku-ui/use-composable' +import type { DismissableLayerProvideValue } from './DismissableLayer' +import { DismissableLayerProvideKey } from './DismissableLayer' + +/* ------------------------------------------------------------------------------------------------- + * DismissableLayerBranch + * ----------------------------------------------------------------------------------------------- */ + +const BRANCH_NAME = 'OkuDismissableLayerBranch' +export type DismissableLayerBranchIntrinsicElement = ElementType<'div'> +export type DismissableLayerBranchElement = HTMLDivElement + +interface DismissableLayerBranchProps extends PrimitiveProps {} + +const DismissableLayerBranch = defineComponent({ + name: BRANCH_NAME, + inheritAttrs: false, + props: { + ...primitiveProps, + }, + setup(props, { attrs }) { + const _inject = inject( + DismissableLayerProvideKey, + ) as DismissableLayerProvideValue + + const node = ref() + + const forwardedRef = useForwardRef() + const composedRefs = useComposedRefs(node, forwardedRef) + + watchEffect((onInvalidate) => { + if (node.value) + _inject.branches.value.add(node.value) + + onInvalidate(() => { + if (node.value && node.value) + _inject.branches.value.delete(node.value) + }) + }) + + const originalReturn = () => + h(Primitive.div, { + ref: composedRefs, + asChild: props.asChild, + ...attrs, + }) + + return originalReturn + }, +}) + +export const OkuDismissableLayerBranch += DismissableLayerBranch as typeof DismissableLayerBranch & +(new () => { + $props: Partial +}) + +export type { DismissableLayerBranchProps } diff --git a/packages/components/dismissable-layer/src/index.ts b/packages/components/dismissable-layer/src/index.ts new file mode 100644 index 000000000..4f32588c2 --- /dev/null +++ b/packages/components/dismissable-layer/src/index.ts @@ -0,0 +1,7 @@ +export * from './DismissableLayerBranch' + +export { OkuDismissableLayer } from './DismissableLayer' + +export type { + DismissableLayerProps, +} from './DismissableLayer' diff --git a/packages/components/dismissable-layer/src/stories/DismissableBox.vue b/packages/components/dismissable-layer/src/stories/DismissableBox.vue new file mode 100644 index 000000000..7fe4c93a8 --- /dev/null +++ b/packages/components/dismissable-layer/src/stories/DismissableBox.vue @@ -0,0 +1,81 @@ + + + diff --git a/packages/components/dismissable-layer/src/stories/DismissableLayer.stories.ts b/packages/components/dismissable-layer/src/stories/DismissableLayer.stories.ts new file mode 100644 index 000000000..5623eca2f --- /dev/null +++ b/packages/components/dismissable-layer/src/stories/DismissableLayer.stories.ts @@ -0,0 +1,187 @@ +import type { Meta, StoryObj } from '@storybook/vue3' +import type { IDismissableLayerProps } from './DismissableLayer.vue' +import OkuDismissableLayerComponent from './DismissableLayer.vue' + +interface StoryProps extends IDismissableLayerProps {} + +const meta = { + title: 'Utilities/DismissableLayer', + component: OkuDismissableLayerComponent, + args: { + template: '#1', + }, + argTypes: { + template: { + control: 'text', + }, + }, + tags: ['autodocs'], +} satisfies Meta & { + args: StoryProps +} + +export default meta +type Story = StoryObj & { + args: StoryProps +} + +export const Basic: Story = { + args: { + template: '#1', + // allShow: true, + }, + + render: (args: any) => ({ + components: { OkuDismissableLayerComponent }, + setup() { + return { args } + }, + template: ` + + `, + }), +} + +export const Nested: Story = { + args: { + template: '#2', + }, + + render: (args: any) => ({ + components: { OkuDismissableLayerComponent }, + setup() { + return { args } + }, + template: ` + + `, + }), +} + +export const WithFocusScope: Story = { + args: { + template: '#3', + }, + + render: (args: any) => ({ + components: { OkuDismissableLayerComponent }, + setup() { + return { args } + }, + template: ` + + `, + }), +} + +export const DialogExample: Story = { + args: { + template: '#4', + }, + + render: (args: any) => ({ + components: { OkuDismissableLayerComponent }, + setup() { + return { args } + }, + template: ` + + `, + }), +} + +export const PopoverFullyModal: Story = { + args: { + template: '#5', + }, + + render: (args: any) => ({ + components: { OkuDismissableLayerComponent }, + setup() { + return { args } + }, + template: ` + + `, + }), +} + +export const PopoverSemiModal: Story = { + args: { + template: '#6', + }, + + render: (args: any) => ({ + components: { OkuDismissableLayerComponent }, + setup() { + return { args } + }, + template: ` + + `, + }), +} + +export const PopoverNonModal: Story = { + args: { + template: '#7', + }, + + render: (args: any) => ({ + components: { OkuDismissableLayerComponent }, + setup() { + return { args } + }, + template: ` + + `, + }), +} + +export const PopoverInDialog: Story = { + args: { + template: '#8', + }, + + render: (args: any) => ({ + components: { OkuDismissableLayerComponent }, + setup() { + return { args } + }, + template: ` + + `, + }), +} + +export const PopoverNested: Story = { + args: { + template: '#9', + }, + + render: (args: any) => ({ + components: { OkuDismissableLayerComponent }, + setup() { + return { args } + }, + template: ` + + `, + }), +} + +export const InPopupWindow: Story = { + args: { + template: '#10', + }, + + render: (args: any) => ({ + components: { OkuDismissableLayerComponent }, + setup() { + return { args } + }, + template: ` + + `, + }), +} diff --git a/packages/components/dismissable-layer/src/stories/DismissableLayer.vue b/packages/components/dismissable-layer/src/stories/DismissableLayer.vue new file mode 100644 index 000000000..509bf39d2 --- /dev/null +++ b/packages/components/dismissable-layer/src/stories/DismissableLayer.vue @@ -0,0 +1,521 @@ + + + + + diff --git a/packages/components/dismissable-layer/src/stories/DummyDialog.vue b/packages/components/dismissable-layer/src/stories/DummyDialog.vue new file mode 100644 index 000000000..9862a530e --- /dev/null +++ b/packages/components/dismissable-layer/src/stories/DummyDialog.vue @@ -0,0 +1,88 @@ + + + diff --git a/packages/components/dismissable-layer/src/stories/DummyPopover.vue b/packages/components/dismissable-layer/src/stories/DummyPopover.vue new file mode 100644 index 000000000..546d208d2 --- /dev/null +++ b/packages/components/dismissable-layer/src/stories/DummyPopover.vue @@ -0,0 +1,142 @@ + + + diff --git a/packages/components/dismissable-layer/src/util.test.ts b/packages/components/dismissable-layer/src/util.test.ts new file mode 100644 index 000000000..f015aa800 --- /dev/null +++ b/packages/components/dismissable-layer/src/util.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { useFocusOutside, usePointerDownOutside } from './util' + +describe('useFocusOutside', () => { + it('should call onFocusOutside when focusin event happens outside', () => { + const onFocusOutside = vi.fn() + + const wrapper = mount({ + template: '
', + setup() { + const events = useFocusOutside(onFocusOutside) + return { events } + }, + }) + + wrapper.trigger('focusin') + + document.dispatchEvent(new FocusEvent('focusin')) + + expect(onFocusOutside).toHaveBeenCalled() + }) + + it('should not call onFocusOutside when focusin event happens inside', () => { + const onFocusOutside = vi.fn() + const wrapper = mount({ + template: '
', + setup() { + const events = useFocusOutside(onFocusOutside) + return { events } + }, + }) + + // Simulate focusin event inside the component + wrapper.find('button').trigger('focusin') + + expect(onFocusOutside).not.toHaveBeenCalled() + }) +}) + +describe('usePointerDownOutside', () => { + it('should not call onPointerDownOutside when pointerdown event happens inside', () => { + const onPointerDownOutside = vi.fn() + const wrapper = mount({ + template: '
', + setup() { + const events = usePointerDownOutside(onPointerDownOutside) + return { events } + }, + }) + + // Simulate pointerdown event inside the component + wrapper.find('button').trigger('pointerdown') + + expect(onPointerDownOutside).not.toHaveBeenCalled() + }) +}) diff --git a/packages/components/dismissable-layer/src/util.ts b/packages/components/dismissable-layer/src/util.ts new file mode 100644 index 000000000..e15436f4e --- /dev/null +++ b/packages/components/dismissable-layer/src/util.ts @@ -0,0 +1,199 @@ +import { useCallbackRef } from '@oku-ui/use-composable' +import { onBeforeUnmount, ref, watchEffect } from 'vue' +import { dispatchDiscreteCustomEvent } from '@oku-ui/primitive' + +import type { Scope } from '@oku-ui/provide' +import { ScopePropObject } from '@oku-ui/provide' +import type { + FocusOutsideEvent, + PointerDownOutsideEvent, +} from './DismissableLayer' +import { + FOCUS_OUTSIDE, + INJECT_UPDATE, + POINTER_DOWN_OUTSIDE, +} from './DismissableLayer' + +export type ScopeDismissableLayer = T & { scopeOkuDismissableLayer?: Scope } + +export const scopeDismissableLayerProps = { + scopeOkuDismissableLayer: { + ...ScopePropObject, + }, +} + +/** + * Listens for `pointerdown` outside a subtree. We use `pointerdown` rather than `pointerup` + * to mimic layer dismissing behaviour present in OS. + * Returns props to pass to the node we want to check for outside events. + */ +function usePointerDownOutside( + onPointerDownOutside?: (event: PointerDownOutsideEvent) => void, + ownerDocument: Document = globalThis?.document, +) { + const handlePointerDownOutside = useCallbackRef( + onPointerDownOutside, + ) as EventListener + + const isPointerInsideTreeRef = ref(false) + const handleClickRef = ref(() => {}) as any + + function handleAndDispatchPointerDownOutsideEvent(event: PointerEvent) { + const eventDetail = { originalEvent: event } + + handleAndDispatchCustomEvent( + POINTER_DOWN_OUTSIDE, + handlePointerDownOutside, + eventDetail, + { discrete: true }, + ) + } + + watchEffect((onInvalidate) => { + const handlePointerDown = (event: PointerEvent) => { + if (event.target && !isPointerInsideTreeRef.value) { + /** + * On touch devices, we need to wait for a click event because browsers implement + * a ~350ms delay between the time the user stops touching the display and when the + * browser executes events. We need to ensure we don't reactivate pointer-events within + * this timeframe otherwise the browser may execute events that should have been prevented. + * + * Additionally, this also lets us deal automatically with cancellations when a click event + * isn't raised because the page was considered scrolled/drag-scrolled, long-pressed, etc. + * + * This is why we also continuously remove the previous listener, because we cannot be + * certain that it was raised, and therefore cleaned-up. + */ + if (event.pointerType === 'touch') { + ownerDocument.removeEventListener('click', handleClickRef.value) + handleClickRef.value = handleAndDispatchPointerDownOutsideEvent + + ownerDocument.addEventListener('click', handleClickRef.value, { + once: true, + }) + } + else { + handleAndDispatchPointerDownOutsideEvent(event) + } + } + else { + // We need to remove the event listener in case the outside click has been canceled. + // See: https://github.com/radix-ui/primitives/issues/2171 + ownerDocument.removeEventListener('click', handleClickRef.value) + } + isPointerInsideTreeRef.value = false + } + + /** + * if this hook executes in a component that mounts via a `pointerdown` event, the event + * would bubble up to the document and trigger a `pointerDownOutside` event. We avoid + * this by delaying the event listener registration on the document. + * This is not specific, but rather how the DOM works, ie: + * ``` + * button.addEventListener('pointerdown', () => { + * console.log('I will log'); + * document.addEventListener('pointerdown', () => { + * console.log('I will also log'); + * }) + * }); + */ + const timerId = window.setTimeout(() => { + ownerDocument.addEventListener('pointerdown', handlePointerDown) + }, 0) + + onBeforeUnmount(() => { + clearTimeout(timerId) + + ownerDocument.removeEventListener('pointerdown', handlePointerDown) + ownerDocument.removeEventListener('click', handleClickRef.value) + }) + + onInvalidate(() => { + window.clearTimeout(timerId) + }) + }) + + return { + onPointerDownCapture: () => (isPointerInsideTreeRef.value = true), + } +} + +/** + * Listens for when focus happens outside a react subtree. + * Returns props to pass to the root (node) of the subtree we want to check. + */ +function useFocusOutside( + onFocusOutside?: (event: FocusOutsideEvent) => void, + ownerDocument: Document = globalThis?.document, +) { + const handleFocusOutside = useCallbackRef(onFocusOutside) as EventListener + const isFocusInsideReactTreeRef = ref(false) + + const handleFocus = (event: FocusEvent) => { + if (event.target && !isFocusInsideReactTreeRef.value) { + const eventDetail = { originalEvent: event } + + handleAndDispatchCustomEvent( + FOCUS_OUTSIDE, + handleFocusOutside, + eventDetail, + { + discrete: false, + }, + ) + } + } + + watchEffect((onInvalidate) => { + ownerDocument.addEventListener('focusin', handleFocus) + + onInvalidate(() => { + ownerDocument.removeEventListener('focusin', handleFocus) + }) + }) + + return { + onFocusCapture: () => (isFocusInsideReactTreeRef.value = true), + onBlurCapture: () => (isFocusInsideReactTreeRef.value = false), + } +} + +function dispatchUpdate() { + const event = new CustomEvent(INJECT_UPDATE) + document.dispatchEvent(event) +} + +function handleAndDispatchCustomEvent< + E extends CustomEvent, + OriginalEvent extends Event, +>( + name: string, + handler: ((event: E) => void) | undefined, + detail: { originalEvent: OriginalEvent } & (E extends CustomEvent + ? D + : never), + { discrete }: { discrete: boolean }, +) { + const target = detail.originalEvent.target + + const event = new CustomEvent(name, { + bubbles: false, + cancelable: true, + detail, + }) + + if (handler) + target.addEventListener(name, handler as EventListener, { once: true }) + + if (discrete) + dispatchDiscreteCustomEvent(target, event) + else + target.dispatchEvent(event) +} + +export { + usePointerDownOutside, + handleAndDispatchCustomEvent, + useFocusOutside, + dispatchUpdate, +} diff --git a/packages/core/focus-group/tsconfig.json b/packages/components/dismissable-layer/tsconfig.json similarity index 100% rename from packages/core/focus-group/tsconfig.json rename to packages/components/dismissable-layer/tsconfig.json diff --git a/packages/components/dismissable-layer/tsup.config.ts b/packages/components/dismissable-layer/tsup.config.ts new file mode 100644 index 000000000..a2f7a0d8b --- /dev/null +++ b/packages/components/dismissable-layer/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/focus-scope/src/focus-scope.ts b/packages/components/focus-scope/src/focus-scope.ts index 9e2add9e6..80f04bfb1 100644 --- a/packages/components/focus-scope/src/focus-scope.ts +++ b/packages/components/focus-scope/src/focus-scope.ts @@ -1,14 +1,24 @@ import { Primitive, primitiveProps } from '@oku-ui/primitive' -import type { - ElementType, - PrimitiveProps, -} from '@oku-ui/primitive' +import type { ElementType, PrimitiveProps } from '@oku-ui/primitive' import { useComposedRefs, useForwardRef } from '@oku-ui/use-composable' -import { defineComponent, h, nextTick, reactive, ref, toRefs, watchEffect } from 'vue' - -import { focus, focusFirst, getTabbableCandidates, getTabbableEdges } from './utils' +import { + defineComponent, + h, + nextTick, + reactive, + ref, + toRefs, + watchEffect, +} from 'vue' + +import { + focus, + focusFirst, + getTabbableCandidates, + getTabbableEdges, +} from './utils' import { focusScopesStack, removeLinks } from './focus-scope-stack' const AUTOFOCUS_ON_MOUNT = 'okuFocusScope.autoFocusOnMount' @@ -70,18 +80,14 @@ const focusScope = defineComponent({ /** * Event handler called when auto-focusing on unmount. * Can be prevented. - */ + */ // eslint-disable-next-line unused-imports/no-unused-vars unmountAutoFocus: (event: Event) => true, }, setup(props, { slots, attrs, emit }) { const { ...focusScopeAttrs } = attrs as FocusScopeElement - const { - loop, - trapped, - asChild, - } = toRefs(props) + const { loop, trapped, asChild } = toRefs(props) const container = ref(null) const lastFocusedElementRef = ref(null) @@ -109,8 +115,7 @@ const focusScope = defineComponent({ const target = event.target as HTMLElement | null if (container.value?.contains(target)) lastFocusedElementRef.value = target - else - focus(lastFocusedElementRef.value, { select: true }) + else focus(lastFocusedElementRef.value, { select: true }) } const handleFocusOut = (event: FocusEvent) => { @@ -152,9 +157,16 @@ const focusScope = defineComponent({ document.addEventListener('focusin', handleFocusIn) document.addEventListener('focusout', handleFocusOut) - const mutationObserver: MutationObserver = new MutationObserver(handleMutations) - if (container.value) - mutationObserver.observe(container.value, { childList: true, subtree: true }) + const mutationObserver: MutationObserver = new MutationObserver( + handleMutations, + ) + if (container.value) { + mutationObserver.observe(container.value, { + childList: true, + subtree: true, + }) + } + onInvalidate(() => { document.removeEventListener('focusin', handleFocusIn) document.removeEventListener('focusout', handleFocusOut) @@ -168,8 +180,11 @@ const focusScope = defineComponent({ if (container.value) { focusScopesStack.add(focusScope) - const previouslyFocusedElement = document.activeElement as HTMLElement | null - const hasFocusedCandidate = container.value?.contains(previouslyFocusedElement) + const previouslyFocusedElement + = document.activeElement as HTMLElement | null + const hasFocusedCandidate = container.value?.contains( + previouslyFocusedElement, + ) if (!hasFocusedCandidate) { const mountEvent = new CustomEvent(AUTOFOCUS_ON_MOUNT, EVENT_OPTIONS) @@ -179,7 +194,9 @@ const focusScope = defineComponent({ container.value?.dispatchEvent(mountEvent) if (!mountEvent.defaultPrevented) { - focusFirst(removeLinks(getTabbableCandidates(container.value)), { select: true }) + focusFirst(removeLinks(getTabbableCandidates(container.value)), { + select: true, + }) if (document.activeElement === previouslyFocusedElement) focus(container.value) } @@ -193,17 +210,26 @@ const focusScope = defineComponent({ // We need to delay the focus a little to get around it for now. // See: https://github.com/facebook/react/issues/17894 setTimeout(() => { - const unmountEvent = new CustomEvent(AUTOFOCUS_ON_UNMOUNT, EVENT_OPTIONS) + const unmountEvent = new CustomEvent( + AUTOFOCUS_ON_UNMOUNT, + EVENT_OPTIONS, + ) container.value?.addEventListener(AUTOFOCUS_ON_UNMOUNT, (event) => { emit('unmountAutoFocus', event) }) container.value?.dispatchEvent(unmountEvent) - if (!unmountEvent.defaultPrevented) - focus(previouslyFocusedElement ?? document.body, { select: true }) + if (!unmountEvent.defaultPrevented) { + focus(previouslyFocusedElement ?? document.body, { + select: true, + }) + } // we need to remove the listener after we `dispatchEvent` - container.value?.removeEventListener(AUTOFOCUS_ON_UNMOUNT, (event) => { - emit('unmountAutoFocus', event) - }) + container.value?.removeEventListener( + AUTOFOCUS_ON_UNMOUNT, + (event) => { + emit('unmountAutoFocus', event) + }, + ) focusScopesStack.remove(focusScope) }, 0) @@ -218,7 +244,11 @@ const focusScope = defineComponent({ if (focusScope.paused) return - const isTabKey = event.key === 'Tab' && !event.altKey && !event.ctrlKey && !event.metaKey + const isTabKey + = event.key === 'Tab' + && !event.altKey + && !event.ctrlKey + && !event.metaKey const focusedElement = document.activeElement as HTMLElement | null if (isTabKey && focusedElement) { @@ -246,22 +276,26 @@ const focusScope = defineComponent({ } } - const originalReturn = () => h(Primitive.div, { - tabIndex: -1, - ref: composedRefs, - onKeydown: handleKeyDown, - ...focusScopeAttrs, - asChild: asChild.value, - }, { - default: () => slots.default?.(), - }) + const originalReturn = () => + h( + Primitive.div, + { + tabIndex: -1, + ref: composedRefs, + onKeydown: handleKeyDown, + ...focusScopeAttrs, + asChild: asChild.value, + }, + { + default: () => slots.default?.(), + }, + ) return originalReturn }, }) export const OkuFocusScope = focusScope as typeof focusScope & -(new () => { $props: Partial -}) +(new () => { $props: Partial }) export type { FocusScopeProps } diff --git a/packages/components/switch/src/Switch.ts b/packages/components/switch/src/Switch.ts index 54ace9977..d3577758f 100644 --- a/packages/components/switch/src/Switch.ts +++ b/packages/components/switch/src/Switch.ts @@ -9,12 +9,12 @@ import { toValue, useModel, } from 'vue' -import { useComposedRefs, useControllable, useForwardRef } from '@oku-ui/use-composable' -import type { - ElementType, - PrimitiveProps, - -} from '@oku-ui/primitive' +import { + useComposedRefs, + useControllable, + useForwardRef, +} from '@oku-ui/use-composable' +import type { ElementType, PrimitiveProps } from '@oku-ui/primitive' import { Primitive, primitiveProps } from '@oku-ui/primitive' import { createProvideScope } from '@oku-ui/provide' import { composeEventHandlers } from '@oku-ui/utils' @@ -105,19 +105,22 @@ const Switch = defineComponent({ name, } = toRefs(props) - const { ...switchProps } = attrs as SwitchIntrinsicElement + const { ...switchAttrs } = attrs as SwitchIntrinsicElement const buttonRef = ref(null) + const forwardedRef = useForwardRef() const composedRefs = useComposedRefs(buttonRef, forwardedRef) const modelValue = useModel(props, 'modelValue') const proxyChecked = computed({ - get: () => modelValue.value !== undefined - ? modelValue.value - : (checkedProp.value !== undefined ? checkedProp.value : undefined), - set: () => { - }, + get: () => + modelValue.value !== undefined + ? modelValue.value + : checkedProp.value !== undefined + ? checkedProp.value + : undefined, + set: () => {}, }) const isFormControl = ref(false) @@ -127,7 +130,7 @@ const Switch = defineComponent({ onMounted(() => { isFormControl.value = buttonRef.value ? typeof buttonRef.value.closest === 'function' - && Boolean(buttonRef.value.closest('form')) + && Boolean(buttonRef.value.closest('form')) : true }) @@ -160,22 +163,25 @@ const Switch = defineComponent({ 'data-state': getState(state.value), 'ref': composedRefs, 'asChild': props.asChild, - ...switchProps, - 'onClick': composeEventHandlers((e) => { - emit('click', e) - }, (event) => { - updateValue(!state.value) - - if (isFormControl.value) { - // hasConsumerStoppedPropagationRef.value - // = event.isPropagationStopped() - // if switch is in a form, stop propagation from the button so that we only propagate - // one click event (from the input). We propagate changes from an input so that native - // form validation works and form events reflect switch updates. - if (!hasConsumerStoppedPropagationRef.value) - event.stopPropagation() - } - }), + ...switchAttrs, + 'onClick': composeEventHandlers( + (e) => { + emit('click', e) + }, + (event) => { + updateValue(!state.value) + + if (isFormControl.value) { + // hasConsumerStoppedPropagationRef.value + // = event.isPropagationStopped() + // if switch is in a form, stop propagation from the button so that we only propagate + // one click event (from the input). We propagate changes from an input so that native + // form validation works and form events reflect switch updates. + if (!hasConsumerStoppedPropagationRef.value) + event.stopPropagation() + } + }, + ), }, { default: () => slots.default?.(), diff --git a/packages/core/focus-group/src/focusGroup.test.ts b/packages/core/focus-group/src/focusGroup.test.ts deleted file mode 100644 index 56cb9b754..000000000 --- a/packages/core/focus-group/src/focusGroup.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { mount } from '@vue/test-utils' -import type { Component } from 'vue' -import { h } from 'vue' -import { createFocusGuard } from './utils' -import { OkuFocusGroup } from './' - -const component = { - setup(props, { attrs, slots }) { - return () => h(OkuFocusGroup, { ...attrs }, slots) - }, -} as Component - -describe('Focus Guards', () => { - it('correctly adds and removes focus guards', () => { - const wrapper = mount(component, { - slots: { - default: '
content
', - }, - }) - - // Focus protection elements are checked after first render - const focusGuards = document.querySelectorAll('[data-oku-focus-guard]') - expect(focusGuards.length).toBe(2) // Focus protection element must be added to the first and last - - // Destroy the component and verify that the focus guards are removed - wrapper.unmount() - const remainingFocusGuards = document.querySelectorAll('[data-oku-focus-guard]') - expect(remainingFocusGuards.length).toBe(0) - }) - - it('creates focus guard element correctly', () => { - const focusGuard = createFocusGuard() - expect(focusGuard.tagName).toBe('SPAN') - expect(focusGuard.getAttribute('data-oku-focus-guard')).toBe('') - expect(focusGuard.tabIndex).toBe(0) - expect(focusGuard.style.outline).toBe('none none') - expect(focusGuard.style.opacity).toBe('0') - expect(focusGuard.style.position).toBe('fixed') - expect(focusGuard.style.pointerEvents).toBe('none') - }) -}) diff --git a/packages/core/focus-group/README.md b/packages/core/focus-guards/README.md similarity index 100% rename from packages/core/focus-group/README.md rename to packages/core/focus-guards/README.md diff --git a/packages/core/focus-guards/build.config.ts b/packages/core/focus-guards/build.config.ts new file mode 100644 index 000000000..b972b9a78 --- /dev/null +++ b/packages/core/focus-guards/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/core/focus-group/package.json b/packages/core/focus-guards/package.json similarity index 91% rename from packages/core/focus-group/package.json rename to packages/core/focus-guards/package.json index 982e6ec8c..fdd9b1113 100644 --- a/packages/core/focus-group/package.json +++ b/packages/core/focus-guards/package.json @@ -1,5 +1,5 @@ { - "name": "@oku-ui/focus-group", + "name": "@oku-ui/focus-guards", "type": "module", "version": "0.2.3", "license": "MIT", @@ -9,7 +9,7 @@ "repository": { "type": "git", "url": "git+https://github.com/oku-ui/primitives.git", - "directory": "packages/core/focus-group" + "directory": "packages/core/focus-guards" }, "bugs": { "url": "https://github.com/oku-ui/primitives/issues" diff --git a/packages/core/focus-guards/src/focusGuards.test.ts b/packages/core/focus-guards/src/focusGuards.test.ts new file mode 100644 index 000000000..70ad0dd7e --- /dev/null +++ b/packages/core/focus-guards/src/focusGuards.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, test } from 'vitest' +import { mount } from '@vue/test-utils' +import type { Component } from 'vue' +import { h } from 'vue' +import { createFocusGuard } from './utils' +import { OkuFocusGuards } from './' + +const component = { + setup(props, { attrs, slots }) { + return () => h(OkuFocusGuards, { ...attrs }, slots) + }, +} as Component + +describe('Focus Guards', () => { + it('correctly adds and removes focus guards', () => { + const wrapper = mount(component, { + slots: { + default: '
content
', + }, + }) + + // Focus protection elements are checked after first render + const focusGuards = document.querySelectorAll('[data-oku-focus-guard]') + expect(focusGuards.length).toBe(2) // Focus protection element must be added to the first and last + + // Destroy the component and verify that the focus guards are removed + wrapper.unmount() + const remainingFocusGuards = document.querySelectorAll('[data-oku-focus-guard]') + expect(remainingFocusGuards.length).toBe(0) + }) + + it('creates focus guard element correctly', () => { + const focusGuard = createFocusGuard() + expect(focusGuard.tagName).toBe('SPAN') + expect(focusGuard.getAttribute('data-oku-focus-guard')).toBe('') + expect(focusGuard.tabIndex).toBe(0) + expect(focusGuard.style.outline).toBe('none none') + expect(focusGuard.style.opacity).toBe('0') + expect(focusGuard.style.position).toBe('fixed') + expect(focusGuard.style.pointerEvents).toBe('none') + }) +}) + +describe('OkuFocusGuards', () => { + beforeEach(() => { + document.body.innerHTML = '' + }) + + test('adds focus guards when used', async () => { + const wrapper = mount(OkuFocusGuards) + + await wrapper.vm.$nextTick() + + const edgeGuards = document.querySelectorAll( + '[data-oku-focus-guard]', + ) + + expect(edgeGuards.length).toBe(2) + + const firstGuard = edgeGuards[0] + const lastGuard = edgeGuards[1] + + expect(firstGuard.getAttribute('data-oku-focus-guard')).toBe('') + expect(lastGuard.getAttribute('data-oku-focus-guard')).toBe('') + + wrapper.unmount() + }) + + test('removes focus guards on unmount if count is 1', async () => { + const mockGuard = document.createElement('span') + mockGuard.setAttribute('data-oku-focus-guard', '') + document.body.appendChild(mockGuard) + + const wrapper = mount(OkuFocusGuards) + + await wrapper.vm.$nextTick() + + wrapper.unmount() + + const edgeGuards = document.querySelectorAll( + '[data-oku-focus-guard]', + ) + expect(edgeGuards.length).toBe(0) + }) + + test('does not remove focus guards on unmount if count is greater than 1', async () => { + const mockGuard = document.createElement('span') + mockGuard.setAttribute('data-oku-focus-guard', '') + document.body.appendChild(mockGuard) + + const wrapper1 = mount(OkuFocusGuards) + const wrapper2 = mount(OkuFocusGuards) + + await wrapper1.vm.$nextTick() + await wrapper2.vm.$nextTick() + + wrapper1.unmount() + + const edgeGuards = document.querySelectorAll( + '[data-oku-focus-guard]', + ) + expect(edgeGuards.length).toBe(1) + + wrapper2.unmount() + }) + + test('adds and removes focus guards in the correct order', async () => { + const wrapper = mount(OkuFocusGuards) + + await wrapper.vm.$nextTick() + + const edgeGuards = document.querySelectorAll( + '[data-oku-focus-guard]', + ) + + expect(edgeGuards.length).toBe(2) + + const firstGuard = edgeGuards[0] + const lastGuard = edgeGuards[1] + + expect(firstGuard.getAttribute('data-oku-focus-guard')).toBe('') + expect(lastGuard.getAttribute('data-oku-focus-guard')).toBe('') + + wrapper.unmount() + + const edgeGuardsAfterUnmount = document.querySelectorAll( + '[data-oku-focus-guard]', + ) + expect(edgeGuardsAfterUnmount.length).toBe(0) + }) +}) diff --git a/packages/core/focus-group/src/focusGroup.ts b/packages/core/focus-guards/src/focusGuards.ts similarity index 70% rename from packages/core/focus-group/src/focusGroup.ts rename to packages/core/focus-guards/src/focusGuards.ts index 6565a7357..709278668 100644 --- a/packages/core/focus-group/src/focusGroup.ts +++ b/packages/core/focus-guards/src/focusGuards.ts @@ -1,8 +1,8 @@ import { defineComponent } from 'vue' import { useFocusGuards } from './utils' -export const OkuFocusGroup = defineComponent({ - name: 'OkuFocusGroup', +export const OkuFocusGuards = defineComponent({ + name: 'OkuFocusGuards', setup(props, { slots }) { useFocusGuards() diff --git a/packages/core/focus-group/src/index.ts b/packages/core/focus-guards/src/index.ts similarity index 56% rename from packages/core/focus-group/src/index.ts rename to packages/core/focus-guards/src/index.ts index 8f6ea4b5a..f899c3e21 100644 --- a/packages/core/focus-group/src/index.ts +++ b/packages/core/focus-guards/src/index.ts @@ -1,6 +1,6 @@ export { - OkuFocusGroup, -} from './focusGroup' + OkuFocusGuards, +} from './focusGuards' export { useFocusGuards, diff --git a/packages/core/focus-group/src/utils.ts b/packages/core/focus-guards/src/utils.ts similarity index 100% rename from packages/core/focus-group/src/utils.ts rename to packages/core/focus-guards/src/utils.ts diff --git a/packages/core/focus-guards/tsconfig.json b/packages/core/focus-guards/tsconfig.json new file mode 100644 index 000000000..b8dfa9041 --- /dev/null +++ b/packages/core/focus-guards/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "tsconfig/node16.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": [ + "src" + ] +} diff --git a/packages/core/focus-group/tsup.config.ts b/packages/core/focus-guards/tsup.config.ts similarity index 100% rename from packages/core/focus-group/tsup.config.ts rename to packages/core/focus-guards/tsup.config.ts diff --git a/packages/core/primitive/src/index.ts b/packages/core/primitive/src/index.ts index 3b7ace858..728f66840 100644 --- a/packages/core/primitive/src/index.ts +++ b/packages/core/primitive/src/index.ts @@ -12,4 +12,6 @@ export type { export { primitiveProps, renderSlotFragments } from './utils' +export { dispatchDiscreteCustomEvent } 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 28539e293..70cc01081 100644 --- a/packages/core/primitive/src/primitive.test.ts +++ b/packages/core/primitive/src/primitive.test.ts @@ -1,8 +1,8 @@ -import { describe, expect, it, test } from 'vitest' +import { describe, expect, it, test, vi } from 'vitest' import { mount } from '@vue/test-utils' +import { h, nextTick } from 'vue' import type { Component } from 'vue' -import { h } from 'vue' -import { Primitive } from './index' +import { Primitive, dispatchDiscreteCustomEvent } from './index' const componentDiv = { setup(props, { slots }) { @@ -349,7 +349,9 @@ describe('Primitive', () => { default: '
Oku
', }, }) - expect(wrapper.html()).toBe('
Oku
') + expect(wrapper.html()).toBe( + '
Oku
', + ) }) test('asChild with props', () => { @@ -366,6 +368,50 @@ describe('Primitive', () => { default: '
Oku
', }, }) - expect(wrapper.html()).toBe('
Oku
') + expect(wrapper.html()).toBe( + '
Oku
', + ) + }) +}) + +describe('dispatchDiscreteCustomEvent', () => { + it('should dispatch the custom event on the target element', async () => { + const customEventName = 'customEvent' + + // Create a mock target element + const targetElement = document.createElement('div') + document.body.appendChild(targetElement) + + // Spy on the dispatchEvent method + const dispatchEventSpy = vi.spyOn(targetElement, 'dispatchEvent') + + // Dispatch the custom event + const customEvent = new CustomEvent(customEventName) + dispatchDiscreteCustomEvent(targetElement, customEvent) + + // Wait for the next tick to ensure event is dispatched + await nextTick() + + // Check if dispatchEvent was called with the correct event + expect(dispatchEventSpy).toHaveBeenCalledWith(customEvent) + + // Clean up + dispatchEventSpy.mockRestore() + document.body.removeChild(targetElement) + }) + + it('should not dispatch the custom event on null or undefined target', async () => { + const customEventName = 'customEvent' + + // Mock dispatchEvent and check if it was not called + const dispatchEventSpy = vi.spyOn(document, 'dispatchEvent') + dispatchDiscreteCustomEvent(null, new CustomEvent(customEventName)) + + // Wait for the next tick to ensure event is not dispatched + await nextTick() + + expect(dispatchEventSpy).not.toHaveBeenCalled() + + dispatchEventSpy.mockRestore() }) }) diff --git a/packages/core/primitive/src/utils.ts b/packages/core/primitive/src/utils.ts index 79c9fab24..c018a705f 100644 --- a/packages/core/primitive/src/utils.ts +++ b/packages/core/primitive/src/utils.ts @@ -1,7 +1,7 @@ // same inspiration and resource https://github.com/chakra-ui/ark/blob/main/packages/vue/src/factory.tsx import type { VNode } from 'vue' -import { Fragment } from 'vue' +import { Fragment, nextTick } from 'vue' /** * Checks whether a given VNode is a render-vialble element. @@ -9,9 +9,9 @@ import { Fragment } from 'vue' export function isValidVNodeElement(input: any): boolean { return ( input - && (typeof input.type === 'string' - || typeof input.type === 'object' - || typeof input.type === 'function') + && (typeof input.type === 'string' + || typeof input.type === 'object' + || typeof input.type === 'function') ) } @@ -43,6 +43,17 @@ export function renderSlotFragments(children: VNode[]): VNode[] { }) } +export function dispatchDiscreteCustomEvent( + target: E['target'], + event: E, +) { + if (target) { + nextTick(() => { + target.dispatchEvent(event) + }) + } +} + export const primitiveProps = { asChild: Boolean, } diff --git a/packages/core/use-composable/src/useEscapeKeydown.test.ts b/packages/core/use-composable/tests/useEscapeKeydown.test.ts similarity index 96% rename from packages/core/use-composable/src/useEscapeKeydown.test.ts rename to packages/core/use-composable/tests/useEscapeKeydown.test.ts index da78997d5..7ff7412ca 100644 --- a/packages/core/use-composable/src/useEscapeKeydown.test.ts +++ b/packages/core/use-composable/tests/useEscapeKeydown.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { SpyInstance } from 'vitest' import { mount } from '@vue/test-utils' -import { useEscapeKeydown } from './useEscapeKeydown' +import { useEscapeKeydown } from '../src/useEscapeKeydown' describe('useEscapeKeydown', () => { let onEscapeKeyDown: any diff --git a/packages/core/use-composable/tsconfig.json b/packages/core/use-composable/tsconfig.json index b8dfa9041..3b4666929 100644 --- a/packages/core/use-composable/tsconfig.json +++ b/packages/core/use-composable/tsconfig.json @@ -5,6 +5,7 @@ "outDir": "dist" }, "include": [ - "src" + "src", + "tests/useEscapeKeydown.test.ts" ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1cb14c481..f1ea2e82d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,9 +40,12 @@ importers: '@oku-ui/direction': specifier: workspace:^ version: link:packages/components/direction - '@oku-ui/focus-group': + '@oku-ui/dismissable-layer': specifier: workspace:^ - version: link:packages/core/focus-group + version: link:packages/components/dismissable-layer + '@oku-ui/focus-guards': + specifier: workspace:^ + version: link:packages/core/focus-guards '@oku-ui/focus-scope': specifier: workspace:^ version: link:packages/components/focus-scope @@ -369,6 +372,28 @@ importers: specifier: workspace:^ version: link:../../tsconfig + packages/components/dismissable-layer: + dependencies: + '@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 + '@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/focus-scope: dependencies: '@oku-ui/primitive': @@ -711,7 +736,7 @@ importers: specifier: workspace:^ version: link:../../tsconfig - packages/core/focus-group: + packages/core/focus-guards: dependencies: vue: specifier: ^3.3.0 diff --git a/scripts/_utils.ts b/scripts/_utils.ts index 212a3ce13..21440d84c 100644 --- a/scripts/_utils.ts +++ b/scripts/_utils.ts @@ -2,7 +2,12 @@ import { promises as fsp } from 'node:fs' import process from 'node:process' import { resolve } from 'pathe' import { execaSync } from 'execa' -import { determineSemverChange, getGitDiff, loadChangelogConfig, parseCommits } from 'changelogen' +import { + determineSemverChange, + getGitDiff, + loadChangelogConfig, + parseCommits, +} from 'changelogen' export interface Dep { name: string @@ -15,11 +20,19 @@ export type Package = ThenArg> export async function loadPackage(dir: string) { const pkgPath = resolve(dir, 'package.json') - const data = JSON.parse(await fsp.readFile(pkgPath, 'utf-8').catch(() => '{}')) - const save = () => fsp.writeFile(pkgPath, `${JSON.stringify(data, null, 2)}\n`) + const data = JSON.parse( + await fsp.readFile(pkgPath, 'utf-8').catch(() => '{}'), + ) + const save = () => + fsp.writeFile(pkgPath, `${JSON.stringify(data, null, 2)}\n`) const updateDeps = (reviver: (dep: Dep) => Dep | void) => { - for (const type of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) { + for (const type of [ + 'dependencies', + 'devDependencies', + 'optionalDependencies', + 'peerDependencies', + ]) { if (!data[type]) continue for (const e of Object.entries(data[type])) { @@ -52,7 +65,10 @@ export async function loadWorkspace(dir: string) { }) } - const setVersion = (newVersion: string, opts: { updateDeps?: boolean } = {}) => { + const setVersion = ( + newVersion: string, + opts: { updateDeps?: boolean } = {}, + ) => { workspacePkg.data.version = newVersion } @@ -78,7 +94,11 @@ export async function determineBumpType() { export async function getLatestCommits() { const config = await loadChangelogConfig(process.cwd()) - const latestTag = execaSync('git', ['describe', '--tags', '--abbrev=0']).stdout + const latestTag = execaSync('git', [ + 'describe', + '--tags', + '--abbrev=0', + ]).stdout return parseCommits(await getGitDiff(latestTag), config) }