Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions components/popover/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
src
11 changes: 11 additions & 0 deletions components/popover/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# `@byndyusoft-ui/popover`

---

> React component that displays modal content over clicked element

## Installation

```
npm i @byndyusoft-ui/popover
```
35 changes: 35 additions & 0 deletions components/popover/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"name": "@byndyusoft-ui/popover",
"version": "0.0.1",
"description": "Byndyusoft UI popover React Component",
"keywords": [
"byndyusoft",
"byndyusoft-ui",
"react",
"popover"
],
"author": "Byndyusoft Frontend Developer <frontend@byndyusoft.com>",
"homepage": "https://github.com/Byndyusoft/ui/tree/master/components/popover#readme",
"license": "Apache-2.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"repository": {
"type": "git",
"url": "git+https://github.com/Byndyusoft/ui.git"
},
"scripts": {
"build": "rollup --config",
"clean": "rimraf dist",
"lint": "eslint src --config ../../eslint.config.js",
"test": "jest --config ../../jest.config.js --roots components/popover/src"
},
"bugs": {
"url": "https://github.com/Byndyusoft/ui/issues"
},
"publishConfig": {
"access": "public"
},
"dependencies": {
"@floating-ui/react": "^0.26.26"
}
}
11 changes: 11 additions & 0 deletions components/popover/rollup.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import typescript from '@rollup/plugin-typescript';
import baseConfig from '../../rollup.base.config';

export default {
...baseConfig,
input: ['src/index.ts'],
plugins: [
...baseConfig.plugins,
typescript({ tsconfig: './tsconfig.json', exclude: ['src/**/*.stories.tsx', 'src/**/*.tests.tsx', 'node_modules'] })
]
};
16 changes: 16 additions & 0 deletions components/popover/src/Popover.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Placement, useFloating, useInteractions } from '@floating-ui/react';

export interface IPopoverOptions {
initialOpen?: boolean;
shouldCloseOnClickOutside?: boolean;
offset?: number;
fitContentWidthByContainer?: boolean;
placement?: Placement;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}

export interface IPopoverContextValue extends ReturnType<typeof useInteractions>, ReturnType<typeof useFloating> {
open: boolean;
setOpen: (arg: boolean) => void;
}
26 changes: 26 additions & 0 deletions components/popover/src/PopoverContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React, { createContext, useContext, PropsWithChildren } from 'react';
import usePopover from './hooks/usePopover';
import { IPopoverOptions, IPopoverContextValue } from './Popover.types';

const PopoverContext = createContext<IPopoverContextValue>({} as IPopoverContextValue);

export const usePopoverContext = (): IPopoverContextValue => {
const context = useContext(PopoverContext);

// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (context === undefined) {
throw new Error('Popover components must be wrapped in PopoverContext');
}

return context;
};

interface IPopoverProviderProps extends PropsWithChildren<IPopoverOptions> {}

const PopoverProvider = ({ children, ...restOptions }: IPopoverProviderProps): JSX.Element => {
const value = usePopover({ ...restOptions });

return <PopoverContext.Provider value={value}>{children}</PopoverContext.Provider>;
};

export default PopoverProvider;
37 changes: 37 additions & 0 deletions components/popover/src/__stories__/Popover.docs.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Meta, Markdown, Canvas, Source, ArgTypes } from '@storybook/blocks';
import Readme from '../../README.md';
import * as PopoverStories from './Popover.stories';

<Meta title="components/Popover" of={PopoverStories} />

<Markdown>{Readme}</Markdown>

## Usage

To use the component in your project you must:

1. Import the component where you need it:

<Source language="javascript" code="import Popover from '@byndyusoft-ui/popover';" />

2. Call the component and give it the desired props, for example:

- Simple popover

<Canvas sourceState="shown" of={PopoverStories.Default} />

- With portal

<Canvas sourceState="shown" of={PopoverStories.WithPortal} />

- Fit content width by container

<Canvas sourceState="shown" of={PopoverStories.FitContentWidthByContainer} />

- Close on click outside or press esc

<Canvas sourceState="shown" of={PopoverStories.CloseOnClickOutsideOrPressEsc} />

## Props

<ArgTypes of={PopoverStories} />
68 changes: 68 additions & 0 deletions components/popover/src/__stories__/Popover.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import React from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import Popover from '../PopoverContext';
import PopoverTrigger from '../partials/PopoverTrigger';
import PopoverContent from '../partials/PopoverContent';

const meta: Meta<typeof Popover> = {
component: Popover,
title: 'components/Popover',
args: {
initialOpen: undefined,
shouldCloseOnClickOutside: false,
placement: 'bottom-start',
offset: 4,
open: undefined,
onOpenChange: undefined
}
};

type TStory = StoryObj<typeof Popover>;

export const Default: TStory = {
render: args => (
<Popover {...args}>
<PopoverTrigger>Click to show</PopoverTrigger>

<PopoverContent>Click the trigger text to close</PopoverContent>
</Popover>
)
};

export const WithPortal: TStory = {
render: args => (
<Popover {...args}>
<PopoverTrigger>Click to show</PopoverTrigger>

<PopoverContent withPortal>Click the trigger text to close</PopoverContent>
</Popover>
)
};

export const FitContentWidthByContainer: TStory = {
render: args => (
<Popover {...args}>
<PopoverTrigger>Click to show</PopoverTrigger>

<PopoverContent>Click the trigger text to close</PopoverContent>
</Popover>
),
args: {
fitContentWidthByContainer: true
}
};

export const CloseOnClickOutsideOrPressEsc: TStory = {
render: args => (
<Popover {...args}>
<PopoverTrigger>Click to show</PopoverTrigger>

<PopoverContent>Try to click outside or press ESC</PopoverContent>
</Popover>
),
args: {
shouldCloseOnClickOutside: true
}
};

export default meta;
78 changes: 78 additions & 0 deletions components/popover/src/__tests__/Popover.tests.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import Popover from '../PopoverContext';
import PopoverTrigger from '../partials/PopoverTrigger';
import PopoverContent from '../partials/PopoverContent';

const TRIGGER_LABEL = 'Click to show';
const CONTENT_LABEL = 'Click the trigger text to close';

describe('components/Popover', () => {
test('not rendering items when closed', () => {
userEvent.setup();

render(
<Popover>
<PopoverTrigger>{TRIGGER_LABEL}</PopoverTrigger>

<PopoverContent>{CONTENT_LABEL}</PopoverContent>
</Popover>
);

const buttons = screen.getAllByRole('button');

expect(buttons).toHaveLength(1);

const contentLabelRendered = screen.queryByText(CONTENT_LABEL);

expect(contentLabelRendered).not.toBeInTheDocument();
});

test('rendering items when opened', async () => {
userEvent.setup();

render(
<Popover>
<PopoverTrigger>{TRIGGER_LABEL}</PopoverTrigger>

<PopoverContent>{CONTENT_LABEL}</PopoverContent>
</Popover>
);

const buttons = screen.getAllByRole('button');

expect(buttons).toHaveLength(1);

await userEvent.click(buttons[0]);

const contentLabelRendered = screen.queryByText(CONTENT_LABEL);

expect(contentLabelRendered).toBeInTheDocument();
});

test('rendering items asChild', () => {
userEvent.setup();

render(
<Popover>
<PopoverTrigger asChild>
<a href="/" rel="noopener">
{TRIGGER_LABEL}
</a>
</PopoverTrigger>

<PopoverContent>{CONTENT_LABEL}</PopoverContent>
</Popover>
);

const links = screen.getAllByRole('link');

expect(links).toHaveLength(1);

const buttons = screen.queryAllByRole('button');

expect(buttons).toHaveLength(0);
});
});
73 changes: 73 additions & 0 deletions components/popover/src/hooks/usePopover.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { useMemo, useState } from 'react';
import {
autoUpdate,
flip,
offset as offsetMiddleware,
shift,
size,
useClick,
useDismiss,
useFloating,
useInteractions,
useRole
} from '@floating-ui/react';
import { IPopoverContextValue, IPopoverOptions } from '../Popover.types';

const usePopover = ({
initialOpen = false,
shouldCloseOnClickOutside = false,
offset,
fitContentWidthByContainer,
placement = 'bottom',
open: controlledOpen,
onOpenChange: setControlledOpen
}: IPopoverOptions): IPopoverContextValue => {
const [uncontrolledOpen, setUncontrolledOpen] = useState(initialOpen);

const open = controlledOpen ?? uncontrolledOpen;
const setOpen = setControlledOpen ?? setUncontrolledOpen;

const data = useFloating({
placement,
open,
onOpenChange: setOpen,
whileElementsMounted: autoUpdate,
middleware: [
offsetMiddleware(offset),
flip({
crossAxis: placement.includes('-'),
padding: 5
}),
shift({ padding: 5 }),

fitContentWidthByContainer &&
size({
apply({ rects, elements }) {
Object.assign(elements.floating.style, {
width: `${rects.reference.width}px`
});
}
})
]
});

const { context } = data;

const click = useClick(context, { enabled: controlledOpen === undefined });
const dismiss = useDismiss(context, { enabled: shouldCloseOnClickOutside });
const role = useRole(context);

const interactions = useInteractions([click, dismiss, role]);

return useMemo(
() => ({
open,
setOpen,
...interactions,
...data
}),
[open, setOpen, interactions, data]
);
};

export default usePopover;
4 changes: 4 additions & 0 deletions components/popover/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { default as Popover } from './PopoverContext';
export { default as PopoverTrigger } from './partials/PopoverTrigger';
export { default as PopoverContent } from './partials/PopoverContent';
export { default as PopoverClose } from './partials/PopoverClose';
18 changes: 18 additions & 0 deletions components/popover/src/partials/PopoverClose.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import React from 'react';
import { forwardRef, ButtonHTMLAttributes } from 'react';
import { usePopoverContext } from '../PopoverContext';

const PopoverClose = forwardRef<HTMLButtonElement, ButtonHTMLAttributes<HTMLButtonElement>>((props, ref) => {
const { setOpen } = usePopoverContext();

const onClickHandler = (event: React.MouseEvent<HTMLButtonElement>): void => {
props.onClick?.(event);
setOpen(false);
};

return <button type="button" ref={ref} {...props} onClick={onClickHandler} />;
});

PopoverClose.displayName = 'PopoverClose';

export default PopoverClose;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.popoverContent {
z-index: 100;

outline: none;
}
Loading