-
- {icon}
-
-
-
-
-
+ }, [customColor]);
+
+ // Auto close effect
+ useEffect(() => {
+ autoClose();
+ return clearTimeouts;
+ }, [autoClose, clearTimeouts]);
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return clearTimeouts;
+ }, [clearTimeouts]);
+
+ const getIcon = () => (color === colors.success ?
:
);
+
+ if (shouldClose) return null;
+
+ const icon = getIcon();
+ const animationStyle = startAnimation
+ ? hideAnimation(animationTime, notificationPosition)
+ : showAnimation(animationTime, notificationPosition);
+ const classes = className ? `notification ${className}` : 'notification';
+
+ return (
+
+
+ {icon}
- )
- }
-}
+
+
+
+
+
+ );
+};
Notification.propTypes = {
type: propTypes.string,
@@ -136,11 +124,3 @@ Notification.propTypes = {
customColor: propTypes.string,
className: propTypes.string,
};
-
-Notification.defaultProps = {
- type: 'info',
- autoHide: true,
- animationTime: 500,
- hideTime: 5000,
- position: position.bottomRight,
-};
diff --git a/src/Notification.stories.jsx b/src/Notification.stories.jsx
new file mode 100644
index 0000000..a74da16
--- /dev/null
+++ b/src/Notification.stories.jsx
@@ -0,0 +1,323 @@
+import React from 'react';
+import { Notification } from './Notification';
+import { NotificationContainer } from './NotificationContainer';
+
+export default {
+ title: 'Components/Notification',
+ component: Notification,
+ parameters: {
+ layout: 'fullscreen',
+ },
+ argTypes: {
+ type: {
+ control: {
+ type: 'select',
+ options: ['info', 'success', 'warn', 'error'],
+ },
+ },
+ position: {
+ control: {
+ type: 'select',
+ options: ['top-left', 'top-right', 'bottom-left', 'bottom-right'],
+ },
+ },
+ customColor: {
+ control: 'color',
+ },
+ },
+};
+
+const Template = (args) => (
+
+
+
+);
+
+export const Default = Template.bind({});
+Default.args = {
+ label: 'Let me know when you give up.',
+ type: 'info',
+ autoHide: true,
+ position: 'bottom-right',
+};
+
+export const Success = Template.bind({});
+Success.args = {
+ label: 'Congratulations! You did it right.',
+ type: 'success',
+ autoHide: true,
+ position: 'bottom-right',
+};
+
+export const Warning = Template.bind({});
+Warning.args = {
+ label: "I wouldn't do this if I were you...",
+ type: 'warn',
+ autoHide: true,
+ position: 'bottom-right',
+};
+
+export const Error = Template.bind({});
+Error.args = {
+ label: "Oh, well. There's always next time.",
+ type: 'error',
+ autoHide: true,
+ position: 'bottom-right',
+};
+
+export const CustomColor = Template.bind({});
+CustomColor.args = {
+ label: 'Custom colored notification',
+ type: 'info',
+ customColor: '#9C27B0',
+ autoHide: true,
+ position: 'bottom-right',
+};
+
+export const NoAutoHide = Template.bind({});
+NoAutoHide.args = {
+ label: 'This notification will not auto-hide',
+ type: 'info',
+ autoHide: false,
+ position: 'bottom-right',
+};
+
+// Multiple notifications showcase
+const MultipleTemplate = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+export const MultipleNotifications = MultipleTemplate.bind({});
+MultipleNotifications.parameters = {
+ docs: {
+ description: {
+ story:
+ 'Showcase of multiple notifications in different positions simultaneously.',
+ },
+ },
+};
+
+// Stack of notifications in same position
+const StackTemplate = () => (
+
+
+
+
+
+);
+
+export const StackedNotifications = StackTemplate.bind({});
+StackedNotifications.parameters = {
+ docs: {
+ description: {
+ story: 'Multiple notifications stacked in the same position.',
+ },
+ },
+};
+
+// Mixed types showcase
+const MixedTemplate = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+export const MixedNotifications = MixedTemplate.bind({});
+MixedNotifications.parameters = {
+ docs: {
+ description: {
+ story:
+ 'Real-world example with different notification types, positions, and one custom colored notification.',
+ },
+ },
+};
+
+// Animation and timing showcase
+const AnimationTemplate = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+export const AnimationShowcase = AnimationTemplate.bind({});
+AnimationShowcase.parameters = {
+ docs: {
+ description: {
+ story:
+ 'Demonstrates different animation speeds and auto-hide timings. Notifications will auto-hide after different intervals.',
+ },
+ },
+};
+
+// Long text notifications
+const LongTextTemplate = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+export const LongTextNotifications = LongTextTemplate.bind({});
+LongTextNotifications.parameters = {
+ docs: {
+ description: {
+ story:
+ 'Shows how notifications handle different text lengths and content wrapping.',
+ },
+ },
+};
diff --git a/src/NotificationContainer.jsx b/src/NotificationContainer.jsx
new file mode 100644
index 0000000..3b53ba8
--- /dev/null
+++ b/src/NotificationContainer.jsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import propTypes from 'prop-types';
+import './container.css';
+
+export const NotificationContainer = ({ position, children }) => {
+ const containerClass = `notification-container ${position}`;
+
+ return
{children}
;
+};
+
+NotificationContainer.propTypes = {
+ position: propTypes.oneOf([
+ 'top-left',
+ 'top-right',
+ 'bottom-left',
+ 'bottom-right',
+ ]).isRequired,
+ children: propTypes.node.isRequired,
+};
diff --git a/src/consts.js b/src/consts.js
index be0f785..90dfd33 100644
--- a/src/consts.js
+++ b/src/consts.js
@@ -2,17 +2,28 @@ export const colors = {
success: '#62918C',
info: '#72ACD6',
error: '#EE665F',
- warn: '#FCD63F'
+ warn: '#FCD63F',
};
export const position = {
bottomRight: 'bottom-right',
bottomLeft: 'bottom-left',
topLeft: 'top-left',
- topRight: 'top-right'
+ topRight: 'top-right',
};
-export const showAnimation = (time) => ({ animation: `moveIn ${time}ms` });
-
-export const hideAnimation = (time) => ({ animation: `moveOut ${time}ms` });
+export const showAnimation = (time, notificationPosition) => {
+ const isLeft =
+ notificationPosition === position.topLeft ||
+ notificationPosition === position.bottomLeft;
+ const animationName = isLeft ? 'moveInLeft' : 'moveIn';
+ return { animation: `${animationName} ${time}ms ease-out` };
+};
+export const hideAnimation = (time, notificationPosition) => {
+ const isLeft =
+ notificationPosition === position.topLeft ||
+ notificationPosition === position.bottomLeft;
+ const animationName = isLeft ? 'moveOutLeft' : 'moveOut';
+ return { animation: `${animationName} ${time}ms ease-in` };
+};
diff --git a/src/container.css b/src/container.css
new file mode 100644
index 0000000..4221194
--- /dev/null
+++ b/src/container.css
@@ -0,0 +1,36 @@
+.notification-container {
+ position: fixed;
+ z-index: 9999;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ pointer-events: none;
+}
+
+.notification-container.top-left {
+ top: 15px;
+ left: 15px;
+}
+
+.notification-container.top-right {
+ top: 15px;
+ right: 15px;
+}
+
+.notification-container.bottom-left {
+ bottom: 15px;
+ left: 15px;
+ flex-direction: column-reverse;
+}
+
+.notification-container.bottom-right {
+ bottom: 15px;
+ right: 15px;
+ flex-direction: column-reverse;
+}
+
+.notification-container .notification {
+ pointer-events: all;
+ position: relative;
+ margin: 0;
+}
diff --git a/src/style.css b/src/style.css
index ba21345..2794d2c 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1,11 +1,14 @@
.notification {
width: 500px;
- height: 60px;
- box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.1), 0 2px 2px 0 rgba(0, 0, 0, 0.1);
- position: fixed;
- margin: 15px;
+ min-height: 60px;
+ box-shadow:
+ 0 2px 2px 0 rgba(0, 0, 0, 0.1),
+ 0 2px 2px 0 rgba(0, 0, 0, 0.1);
+ position: relative;
display: flex;
align-items: center;
+ background: white;
+ border-radius: 4px;
}
.box {
@@ -19,12 +22,11 @@
.message > p {
margin: 0;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
width: 350px;
color: #585959;
font-family: Cambria, Cochin, Georgia, Times, 'Times New Roman', serif;
+ word-wrap: break-word;
+ line-height: 1.4;
}
.flex-center {
@@ -39,24 +41,45 @@
@keyframes moveIn {
0% {
- transform: translateY(100px);
- visibility: hidden;
+ transform: translateX(100%);
+ opacity: 0;
}
100% {
- transform: translateY(0);
- visibility: visible;
+ transform: translateX(0);
+ opacity: 1;
}
}
@keyframes moveOut {
0% {
- transform: translateY(0);
- opacity: 1;
- visibility: visible;
+ transform: translateX(0);
+ opacity: 1;
+ }
+ 100% {
+ transform: translateX(100%);
+ opacity: 0;
+ }
+}
+
+/* Animacje dla pozycji po lewej stronie */
+@keyframes moveInLeft {
+ 0% {
+ transform: translateX(-100%);
+ opacity: 0;
}
100% {
- transform: translateY(-100px);
- opacity: 0;
- visibility: hidden;
+ transform: translateX(0);
+ opacity: 1;
}
-}
\ No newline at end of file
+}
+
+@keyframes moveOutLeft {
+ 0% {
+ transform: translateX(0);
+ opacity: 1;
+ }
+ 100% {
+ transform: translateX(-100%);
+ opacity: 0;
+ }
+}
diff --git a/tests/index.test.js b/tests/index.test.js
index c6e6700..97ef44d 100644
--- a/tests/index.test.js
+++ b/tests/index.test.js
@@ -3,39 +3,120 @@ import { Notification } from '../src/Notification';
import enzyme from 'enzyme';
import Adapter from '@cfaester/enzyme-adapter-react-18';
import { mount } from 'enzyme';
+import { act } from 'react';
enzyme.configure({ adapter: new Adapter() });
describe('Notification', () => {
let props;
beforeEach(() => {
- props = {
- label: "test",
+ props = {
+ label: 'test',
};
- });
+ jest.clearAllTimers();
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
test('should render correctly', () => {
const wrapper = mount(
);
expect(wrapper).not.toBeNull();
+ expect(wrapper.find('.notification')).toHaveLength(1);
+ expect(wrapper.find('.message p').text()).toBe('test');
wrapper.unmount();
});
- test('should component disappear', () => {
+
+ test('should component disappear with autoHide', async () => {
jest.useFakeTimers();
- const wrapper = mount(
);
- expect(setTimeout.mock.calls.length).toEqual(1);
+ const wrapper = mount(
+
+ );
+
+ // Component should be visible initially with show animation
+ expect(wrapper.find('.notification')).toHaveLength(1);
+ expect(wrapper.find('.notification').prop('style')).toEqual(
+ expect.objectContaining({
+ animation: expect.stringContaining('moveIn'),
+ })
+ );
- jest.runAllTimers();
+ // Fast forward time to trigger auto-hide
+ await act(async () => {
+ jest.advanceTimersByTime(900); // hideTime - animationTime
+ });
+ wrapper.update();
- expect(wrapper.html()).toBeNull();
- wrapper.unmount();
- })
- test('should clear timeout', () => {
+ // Should start hide animation
+ expect(wrapper.find('.notification').prop('style')).toEqual(
+ expect.objectContaining({
+ animation: expect.stringContaining('moveOut'),
+ })
+ );
+
+ wrapper.unmount();
+ });
+
+ test('should clear timeout on unmount', () => {
jest.useFakeTimers();
- const mockTimerValue = 5000;
- setTimeout.mockReturnValue(mockTimerValue);
- const wrapper = mount(
);
+ const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout');
+
+ const wrapper = mount(
);
wrapper.unmount();
- expect(clearTimeout.mock.calls.length).toEqual(2);
- expect(clearTimeout.mock.calls[0][0]).toEqual(mockTimerValue);
- })
+
+ expect(clearTimeoutSpy).toHaveBeenCalled();
+ clearTimeoutSpy.mockRestore();
+ });
+
+ test('should not auto hide when autoHide is false', () => {
+ jest.useFakeTimers();
+
+ const wrapper = mount(
);
+
+ // Fast forward a lot of time
+ jest.advanceTimersByTime(10000);
+ wrapper.update();
+
+ // Component should still be visible
+ expect(wrapper.find('.notification')).toHaveLength(1);
+
+ wrapper.unmount();
+ });
+
+ test('should handle manual close', async () => {
+ jest.useFakeTimers();
+
+ const wrapper = mount(
);
+
+ // Find close button and click it - it's an SVG element
+ const closeButton = wrapper.find('.close').at(0);
+ expect(closeButton).toHaveLength(1);
+
+ // Initially should have show animation
+ expect(wrapper.find('.notification').prop('style')).toEqual(
+ expect.objectContaining({
+ animation: expect.stringContaining('moveIn'),
+ })
+ );
+
+ await act(async () => {
+ closeButton.simulate('click');
+ // Immediately advance timers and allow React to process state updates
+ jest.advanceTimersByTime(500);
+ });
+ wrapper.update();
+
+ // Component should be gone after animation completes
+ expect(wrapper.html()).toBe(null);
+
+ wrapper.unmount();
+ });
});
diff --git a/tests/utils.js b/tests/utils.js
index c5f526b..054f6c1 100644
--- a/tests/utils.js
+++ b/tests/utils.js
@@ -1,9 +1,9 @@
'use strict';
function timerGame(callback, time) {
- setTimeout(() => {
- callback && callback();
- }, time);
-};
+ setTimeout(() => {
+ callback && callback();
+ }, time);
+}
module.exports = timerGame;
diff --git a/vite.config.js b/vite.config.js
new file mode 100644
index 0000000..ec9fe3f
--- /dev/null
+++ b/vite.config.js
@@ -0,0 +1,10 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ plugins: [react()],
+ esbuild: {
+ loader: 'jsx',
+ include: /src\/.*\.[jt]sx?$/,
+ },
+});