From 5a4e09bfdd230044db109163ace193456fad101b Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Tue, 9 Jun 2026 14:30:11 +0200 Subject: [PATCH 01/12] fix: use npm publish instead of yarn publish in GitHub registry workflow yarn publish prompts for version confirmation interactively, causing the CI job to hang and fail silently. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/publish-github.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-github.yml b/.github/workflows/publish-github.yml index c333cbb..d1a9258 100644 --- a/.github/workflows/publish-github.yml +++ b/.github/workflows/publish-github.yml @@ -30,6 +30,6 @@ jobs: run: yarn build - name: Publish to GitHub - run: yarn publish + run: npm publish --scope=@internxt --access public env: NODE_AUTH_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} From 9a1ae99a3a6fadf828f50c2dc424767e546ea71c Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Thu, 18 Jun 2026 09:44:50 +0200 Subject: [PATCH 02/12] feat: UsageWarningBanner --- package.json | 2 +- .../usageBanner/UsageWarningBanner.tsx | 99 +++++++++++++++++++ .../notifications/usageBanner/index.ts | 2 + src/index.scss | 36 +++++++ .../components/usageBanner/Banner.stories.tsx | 71 +++++++++++++ 5 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 src/components/feedback/notifications/usageBanner/UsageWarningBanner.tsx create mode 100644 src/components/feedback/notifications/usageBanner/index.ts create mode 100644 src/stories/components/usageBanner/Banner.stories.tsx diff --git a/package.json b/package.json index c1681ca..998c81c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@internxt/ui", - "version": "0.1.20", + "version": "0.1.21", "description": "Library of Internxt components", "repository": { "type": "git", diff --git a/src/components/feedback/notifications/usageBanner/UsageWarningBanner.tsx b/src/components/feedback/notifications/usageBanner/UsageWarningBanner.tsx new file mode 100644 index 0000000..a5f56d4 --- /dev/null +++ b/src/components/feedback/notifications/usageBanner/UsageWarningBanner.tsx @@ -0,0 +1,99 @@ +import { CloudWarningIcon, XIcon } from '@phosphor-icons/react'; +import React from 'react'; +import { Button } from '../../../input/button'; + +export interface UsageWarningBannerProps { + usage: string; + limit: string; + percentage: number; + titleLabel: string; + descriptionLabelLine1: string; + descriptionLabelLine2: string; + upgradeLabel: string; + onUpgradeClick: () => void; + onCloseButtonClick: () => void; + isLoading?: boolean; + darkMode?: boolean; +} + +type StorageLevel = 'low warning' | 'middle warning' | 'high warning'; + +const getStorageLevel = (percentage: number): StorageLevel => { + if (percentage >= 95) return 'high warning'; + if (percentage >= 80) return 'middle warning'; + return 'low warning'; +}; + +const STORAGE_LEVEL_STYLES: Record = { + 'low warning': { bar: 'bg-yellow-60' }, + 'middle warning': { bar: 'bg-orange-60' }, + 'high warning': { bar: 'bg-danger' }, +}; + + +const renderWithBold = (text: string): React.ReactNode => + text.split(/(\*\*[^*]+\*\*)/g).map((segment, index) => { + if (segment.startsWith('**') && segment.endsWith('**')) { + return {segment.slice(2, -2)}; + } + return {segment}; + }); + +const UsageWarningBanner: React.FC = ({ + usage, + limit, + percentage, + titleLabel, + descriptionLabelLine1, + descriptionLabelLine2, + upgradeLabel, + onUpgradeClick, + onCloseButtonClick, + isLoading = true, +}) => { + const level = getStorageLevel(percentage); + const styles = STORAGE_LEVEL_STYLES[level]; + + return ( +
+
+
+ + +

{titleLabel}

+
+ +
+ +

{renderWithBold(descriptionLabelLine1)}

+

{renderWithBold(descriptionLabelLine2)}

+
+
+
+
+
+
+
+ {isLoading ? ( +
+
+
+
+
+ ) : ( + +

{usage}

+

/

+

{limit}

+
+ )} +
+ +
+
+ ); +}; + +export default UsageWarningBanner; \ No newline at end of file diff --git a/src/components/feedback/notifications/usageBanner/index.ts b/src/components/feedback/notifications/usageBanner/index.ts new file mode 100644 index 0000000..5d2c01a --- /dev/null +++ b/src/components/feedback/notifications/usageBanner/index.ts @@ -0,0 +1,2 @@ +export { default as UsageWarningBanner } from './UsageWarningBanner'; +export type { UsageWarningBannerProps } from './UsageWarningBanner'; \ No newline at end of file diff --git a/src/index.scss b/src/index.scss index d244bc0..28893c8 100644 --- a/src/index.scss +++ b/src/index.scss @@ -1,6 +1,8 @@ @import url('tailwindcss'); @theme { + --color-alert: rgb(var(--color-alert-rgb)); + --color-alert-dark: rgb(var(--color-alert-dark-rgb)); --color-surface: rgb(var(--color-surface-rgb)); --color-highlight: rgb(var(--color-highlight-rgb)); --color-primary: rgb(var(--color-primary-rgb)); @@ -8,8 +10,10 @@ --color-red: rgb(var(--color-red-rgb)); --color-red-dark: rgb(var(--color-red-dark-rgb)); --color-orange: rgb(var(--color-orange-rgb)); + --color-orange-60: rgb(var(--color-orange-60-rgb)); --color-orange-dark: rgb(var(--color-orange-dark-rgb)); --color-yellow: rgb(var(--color-yellow-rgb)); + --color-yellow-60: rgb(var(--color-yellow-60-rgb)); --color-yellow-dark: rgb(var(--color-yellow-dark-rgb)); --color-green: rgb(var(--color-green-rgb)); --color-green-dark: rgb(var(--color-green-dark-rgb)); @@ -24,12 +28,15 @@ --color-gray-30: rgb(var(--color-gray-30-rgb)); --color-gray-40: rgb(var(--color-gray-40-rgb)); --color-gray-50: rgb(var(--color-gray-50-rgb)); + --color-gray-52: rgb(var(--color-gray-52-rgb)); + --color-gray-53: rgb(var(--color-gray-53-rgb)); --color-gray-55: rgb(var(--color-gray-55-rgb)); --color-gray-60: rgb(var(--color-gray-60-rgb)); --color-gray-70: rgb(var(--color-gray-70-rgb)); --color-gray-80: rgb(var(--color-gray-80-rgb)); --color-gray-90: rgb(var(--color-gray-90-rgb)); --color-gray-100: rgb(var(--color-gray-100-rgb)); + --color-danger: rgb(var(--color-danger-rgb)); } button { @@ -45,8 +52,10 @@ button { --color-red-rgb: 255 13 0; --color-red-dark-rgb: 230 11 0; --color-orange-rgb: 255 149 0; + --color-orange-60-rgb: 229 134 0; --color-orange-dark-rgb: 230 134 0; --color-yellow-rgb: 255 204 0; + --color-yellow-60-rgb: 229 183 0; --color-yellow-dark-rgb: 230 184 0; --color-green-rgb: 50 195 86; --color-green-dark-rgb: 45 174 77; @@ -63,12 +72,17 @@ button { --color-gray-30-rgb: 199 199 205; --color-gray-40-rgb: 174 174 179; --color-gray-50-rgb: 142 142 148; + --color-gray-52-rgb: 124 124 126; + --color-gray-53-rgb: 124 124 124; --color-gray-55-rgb: 115 115 115; --color-gray-60-rgb: 99 99 103; --color-gray-70-rgb: 72 72 75; --color-gray-80-rgb: 58 58 59; --color-gray-90-rgb: 44 44 48; --color-gray-100-rgb: 24 24 27; + --color-alert-rgb: 255 249 229; + --color-alert-dark-rgb: 255 244 204; + --color-danger-rgb: 229 11 0; } :root.dark { @@ -79,8 +93,10 @@ button { --color-red-rgb: 255 61 51; --color-red-dark-rgb: 255 36 26; --color-orange-rgb: 255 164 36; + --color-orange-60-rgb: 229 147 32; --color-orange-dark-rgb: 255 153 10; --color-yellow-rgb: 255 214 51; + --color-yellow-60-rgb: 229 192 45; --color-yellow-dark-rgb: 255 209 26; --color-green-rgb: 72 208 106; --color-green-dark-rgb: 52 203 90; @@ -97,12 +113,18 @@ button { --color-gray-30-rgb: 99 99 103; --color-gray-40-rgb: 142 142 148; --color-gray-50-rgb: 174 174 179; + --color-gray-52-rgb: 224 224 226; + --color-gray-53-rgb: 199 199 201; --color-gray-55-rgb: 115 115 115; --color-gray-60-rgb: 199 199 205; --color-gray-70-rgb: 209 209 215; --color-gray-80-rgb: 229 229 235; --color-gray-90-rgb: 243 243 248; --color-gray-100-rgb: 249 249 252; + --color-alert-rgb: 76 64 15; + --color-alert-dark-rgb: 127 107 25; + --color-danger-rgb: 229 54 45; + } } @@ -115,8 +137,10 @@ button { --color-red-rgb: 255 61 51; --color-red-dark-rgb: 255 36 26; --color-orange-rgb: 255 164 36; + --color-orange-60-rgb: 229 147 32; --color-orange-dark-rgb: 255 153 10; --color-yellow-rgb: 255 214 51; + --color-yellow-60-rgb: 229 192 45; --color-yellow-dark-rgb: 255 209 26; --color-green-rgb: 72 208 106; --color-green-dark-rgb: 52 203 90; @@ -133,12 +157,17 @@ button { --color-gray-30-rgb: 99 99 103; --color-gray-40-rgb: 142 142 148; --color-gray-50-rgb: 174 174 179; + --color-gray-52-rgb: 224 224 226; + --color-gray-53-rgb: 199 199 201; --color-gray-55-rgb: 115 115 115; --color-gray-60-rgb: 199 199 205; --color-gray-70-rgb: 209 209 215; --color-gray-80-rgb: 229 229 235; --color-gray-90-rgb: 243 243 248; --color-gray-100-rgb: 249 249 252; + --color-alert-rgb: 76 64 15; + --color-alert-dark-rgb: 127 107 25; + --color-danger-rgb: 229 54 45; } :root:not(.dark) { @@ -149,8 +178,10 @@ button { --color-red-rgb: 255 13 0; --color-red-dark-rgb: 230 11 0; --color-orange-rgb: 255 149 0; + --color-orange-60-rgb: 229 134 0; --color-orange-dark-rgb: 230 134 0; --color-yellow-rgb: 255 204 0; + --color-yellow-60-rgb: 229 183 0; --color-yellow-dark-rgb: 230 184 0; --color-green-rgb: 50 195 86; --color-green-dark-rgb: 45 174 77; @@ -167,11 +198,16 @@ button { --color-gray-30-rgb: 199 199 205; --color-gray-40-rgb: 174 174 179; --color-gray-50-rgb: 142 142 148; + --color-gray-52-rgb: 124 124 126; + --color-gray-53-rgb: 124 124 124; --color-gray-55-rgb: 115 115 115; --color-gray-60-rgb: 99 99 103; --color-gray-70-rgb: 72 72 75; --color-gray-80-rgb: 58 58 59; --color-gray-90-rgb: 44 44 48; --color-gray-100-rgb: 24 24 27; + --color-alert-rgb: 255 249 229; + --color-alert-dark-rgb: 255 244 204; + --color-danger-rgb: 229 11 0; } } diff --git a/src/stories/components/usageBanner/Banner.stories.tsx b/src/stories/components/usageBanner/Banner.stories.tsx new file mode 100644 index 0000000..d0fb0e0 --- /dev/null +++ b/src/stories/components/usageBanner/Banner.stories.tsx @@ -0,0 +1,71 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { fn } from 'storybook/test'; +import { UsageWarningBanner } from '@/components/feedback/notifications/usageBanner'; + +const meta: Meta = { + title: 'Feedback/Banner', + component: UsageWarningBanner, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + percentage: { + control: { type: 'range', min: 0, max: 100, step: 1 }, + }, + isLoading: { + control: { type: 'boolean' }, + }, + }, + args: { + onUpgradeClick: fn(), + onCloseButtonClick: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + usage: '600MB', + limit: '1GB', + percentage: 60, + titleLabel: 'Get more space for your files', + descriptionLabelLine1: 'Unlock additional storage with an exclusive 85% discount on your upgrade', + descriptionLabelLine2: 'Access advanced features like file version history, Rclone, NAS support, premium support, and more', + upgradeLabel: 'Get offer', + isLoading: false, + }, +}; + +export const LowUsage: Story = { + args: { + ...Default.args, + usage: '800MB', + limit: '1GB', + percentage: 80, + titleLabel: 'Your storage is filling up', + descriptionLabelLine1: 'Upgrade today with an exclusive 85% discount and keep uploading without interruptions', + descriptionLabelLine2: 'Get more storage plus advanced features like file version history, NAS support, Rclone integration, and premium support', + }, +}; + +export const AlmostFull: Story = { + args: { + ...Default.args, + usage: '950MB', + limit: '1GB', + percentage: 95, + titleLabel: 'Your storage is almost full', + descriptionLabelLine1: 'You may soon be unable to upload new files', + descriptionLabelLine2: 'Upgrade now with an exclusive 85% discount to continue storing and syncing your files without limits' + }, +}; + +export const Loading: Story = { + args: { + ...Default.args, + isLoading: true, + }, +}; \ No newline at end of file From 2d36f6bcbcedca775955d697df5003eaad4111c5 Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Thu, 18 Jun 2026 09:46:44 +0200 Subject: [PATCH 03/12] add to the export files --- src/components/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/index.ts b/src/components/index.ts index db5baa0..98376dd 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -8,6 +8,7 @@ export * from './data-display/table/Table'; export * from './feedback/empty'; export * from './feedback/loader'; export * from './feedback/skeletonLoader'; +export * from './feedback/notifications/usageBanner'; // input export * from './input/button'; From a61804eac7cb6830e215a00d61a7d675e24a682b Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Thu, 18 Jun 2026 09:53:29 +0200 Subject: [PATCH 04/12] feat: update sidenav storage component and add stories --- .../navigation/sidenav/SidenavStorage.tsx | 38 ++++++++- .../sidenav/SidenavStorage.stories.tsx | 80 +++++++++++++++++++ 2 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 src/stories/components/sidenav/SidenavStorage.stories.tsx diff --git a/src/components/navigation/sidenav/SidenavStorage.tsx b/src/components/navigation/sidenav/SidenavStorage.tsx index 94a8c00..271aec6 100644 --- a/src/components/navigation/sidenav/SidenavStorage.tsx +++ b/src/components/navigation/sidenav/SidenavStorage.tsx @@ -1,3 +1,4 @@ +import { CloudWarningIcon } from '@phosphor-icons/react'; interface SidenavStorageProps { usage: string; limit: string; @@ -5,8 +6,25 @@ interface SidenavStorageProps { onUpgradeClick: () => void; upgradeLabel?: string; isLoading?: boolean; + advertisementMessage?: string; } +type StorageLevel = 'normal' | 'low warning' | 'middle warning' | 'high warning'; + +const getStorageLevel = (percentage: number): StorageLevel => { + if (percentage >= 95) return 'high warning'; + if (percentage >= 80) return 'middle warning'; + if (percentage >= 60) return 'low warning'; + return 'normal'; +}; + +const STORAGE_LEVEL_STYLES: Record = { + normal: { bar: 'bg-gray-60', container: '' }, + 'low warning': { bar: 'bg-yellow-60', container: '' }, + 'middle warning': { bar: 'bg-orange-60', container: '' }, + 'high warning': { bar: 'bg-danger', container: 'bg-alert rounded-xl border border-alert-dark p-3 gap-2' }, +}; + const SidenavStorage = ({ usage, limit, @@ -14,9 +32,25 @@ const SidenavStorage = ({ onUpgradeClick, upgradeLabel, isLoading = true, + advertisementMessage, }: SidenavStorageProps): JSX.Element => { + + const level = getStorageLevel(percentage); + const styles = STORAGE_LEVEL_STYLES[level]; + const showWarning = level === 'middle warning' || level === 'high warning'; + return ( -
+ +
+ {showWarning && advertisementMessage && ( +
+ +

{advertisementMessage}

+
+ )}
{isLoading ? ( @@ -41,7 +75,7 @@ const SidenavStorage = ({
= { + title: 'Navigation/SidenavStorage', + component: SidenavStorage, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + percentage: { + control: { type: 'range', min: 0, max: 100, step: 1 }, + }, + onUpgradeClick: { action: 'upgradeClick' }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + usage: '2.8 GB', + limit: '4 GB', + percentage: 70, + upgradeLabel: 'Upgrade', + isLoading: false, + onUpgradeClick: () => console.log('Upgrade clicked'), + }, +}; + +export const Loading: Story = { + args: { + ...Default.args, + isLoading: true, + }, +}; + +export const HighUsage: Story = { + args: { + ...Default.args, + usage: '9.5 GB', + limit: '10 GB', + percentage: 95, + upgradeLabel: 'Upgrade now', + }, +}; + +export const LowUsage: Story = { + args: { + ...Default.args, + usage: '500 MB', + limit: '4 GB', + percentage: 12, + }, +}; + +export const Full: Story = { + args: { + ...Default.args, + usage: '10 GB', + limit: '10 GB', + percentage: 100, + }, +}; + +export const WithoutUpgradeButton: Story = { + args: { + ...Default.args, + upgradeLabel: undefined, + }, +}; From 9248e9fd4d787f68a20fde1388e879cbcf7f0ce3 Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Thu, 18 Jun 2026 09:55:29 +0200 Subject: [PATCH 05/12] Update publish-github.yml --- .github/workflows/publish-github.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-github.yml b/.github/workflows/publish-github.yml index d1a9258..c333cbb 100644 --- a/.github/workflows/publish-github.yml +++ b/.github/workflows/publish-github.yml @@ -30,6 +30,6 @@ jobs: run: yarn build - name: Publish to GitHub - run: npm publish --scope=@internxt --access public + run: yarn publish env: NODE_AUTH_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} From 0c22d9e934d5f3056c2ea31e6a2f8d06b9c3ece1 Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Thu, 18 Jun 2026 10:04:25 +0200 Subject: [PATCH 06/12] add test --- .../usageBanner/UsageWarningBanner.tsx | 15 ++- .../__test__/UsageWarningBanner.test.tsx | 107 ++++++++++++++++++ 2 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 src/components/feedback/notifications/usageBanner/__test__/UsageWarningBanner.test.tsx diff --git a/src/components/feedback/notifications/usageBanner/UsageWarningBanner.tsx b/src/components/feedback/notifications/usageBanner/UsageWarningBanner.tsx index a5f56d4..6f966e6 100644 --- a/src/components/feedback/notifications/usageBanner/UsageWarningBanner.tsx +++ b/src/components/feedback/notifications/usageBanner/UsageWarningBanner.tsx @@ -10,15 +10,16 @@ export interface UsageWarningBannerProps { descriptionLabelLine1: string; descriptionLabelLine2: string; upgradeLabel: string; + closeButtonLabel?: string; onUpgradeClick: () => void; onCloseButtonClick: () => void; isLoading?: boolean; darkMode?: boolean; } -type StorageLevel = 'low warning' | 'middle warning' | 'high warning'; +export type StorageLevel = 'low warning' | 'middle warning' | 'high warning'; -const getStorageLevel = (percentage: number): StorageLevel => { +export const getStorageLevel = (percentage: number): StorageLevel => { if (percentage >= 95) return 'high warning'; if (percentage >= 80) return 'middle warning'; return 'low warning'; @@ -47,6 +48,7 @@ const UsageWarningBanner: React.FC = ({ descriptionLabelLine1, descriptionLabelLine2, upgradeLabel, + closeButtonLabel = 'Close', onUpgradeClick, onCloseButtonClick, isLoading = true, @@ -62,7 +64,14 @@ const UsageWarningBanner: React.FC = ({

{titleLabel}

- +

{renderWithBold(descriptionLabelLine1)}

diff --git a/src/components/feedback/notifications/usageBanner/__test__/UsageWarningBanner.test.tsx b/src/components/feedback/notifications/usageBanner/__test__/UsageWarningBanner.test.tsx new file mode 100644 index 0000000..e6284f0 --- /dev/null +++ b/src/components/feedback/notifications/usageBanner/__test__/UsageWarningBanner.test.tsx @@ -0,0 +1,107 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { UsageWarningBanner, UsageWarningBannerProps } from '../'; +import { getStorageLevel } from '../UsageWarningBanner'; + +const renderBanner = (overrides: Partial = {}) => { + const props: UsageWarningBannerProps = { + usage: '10 GB', + limit: '20 GB', + percentage: 50, + titleLabel: 'Storage almost full', + descriptionLabelLine1: 'You are running out of space', + descriptionLabelLine2: 'Upgrade to keep saving files', + upgradeLabel: 'Upgrade now', + closeButtonLabel: 'Close', + onUpgradeClick: vi.fn(), + onCloseButtonClick: vi.fn(), + isLoading: false, + ...overrides, + }; + + return { props, ...render() }; +}; + +describe('UsageWarningBanner', () => { + it('warns with a danger-coloured bar when storage is critically full', () => { + const { container } = renderBanner({ percentage: 96 }); + + expect(container.querySelector('.bg-danger')).toBeTruthy(); + }); + + it('warns with an orange bar when storage is nearly full', () => { + const { container } = renderBanner({ percentage: 85 }); + + expect(container.querySelector('.bg-orange-60')).toBeTruthy(); + }); + + it('warns with a yellow bar when storage still has room', () => { + const { container } = renderBanner({ percentage: 40 }); + + expect(container.querySelector('.bg-yellow-60')).toBeTruthy(); + }); + + it('fills the bar in proportion to the storage used', () => { + const { container } = renderBanner({ percentage: 73 }); + + const bar = container.querySelector('.bg-yellow-60'); + expect(bar?.style.width).toBe('73%'); + }); + + it('emphasises the portions of the description wrapped in double asterisks', () => { + renderBanner({ descriptionLabelLine1: 'You have used **90%** of your storage' }); + + expect(screen.getByText('90%').tagName).toBe('STRONG'); + }); + + it('shows a loading placeholder instead of the usage figures while data loads', () => { + const { container } = renderBanner({ isLoading: true }); + + expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0); + expect(screen.queryByText('10 GB')).toBeNull(); + expect(screen.queryByText('20 GB')).toBeNull(); + }); + + it('shows the used and total storage once data has loaded', () => { + renderBanner({ isLoading: false }); + + expect(screen.getByText('10 GB')).toBeTruthy(); + expect(screen.getByText('20 GB')).toBeTruthy(); + }); + + it('notifies the parent when the user chooses to upgrade', () => { + const onUpgradeClick = vi.fn(); + renderBanner({ onUpgradeClick }); + + screen.getByRole('button', { name: 'Upgrade now' }).click(); + + expect(onUpgradeClick).toHaveBeenCalledOnce(); + }); + + it('notifies the parent when the user dismisses the banner', () => { + const onCloseButtonClick = vi.fn(); + renderBanner({ onCloseButtonClick, closeButtonLabel: 'Close' }); + + screen.getByRole('button', { name: 'Close' }).click(); + + expect(onCloseButtonClick).toHaveBeenCalledOnce(); + }); +}); + +describe('getStorageLevel', () => { + it('flags critical usage from ninety-five percent upwards', () => { + expect(getStorageLevel(95)).toBe('high warning'); + expect(getStorageLevel(100)).toBe('high warning'); + }); + + it('flags near-full usage between eighty and ninety-five percent', () => { + expect(getStorageLevel(80)).toBe('middle warning'); + expect(getStorageLevel(94)).toBe('middle warning'); + }); + + it('keeps usage below eighty percent as a low warning', () => { + expect(getStorageLevel(0)).toBe('low warning'); + expect(getStorageLevel(79)).toBe('low warning'); + }); +}); From 8d80fddd9d9e99987ee30adbf7d92950872cb451 Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Thu, 18 Jun 2026 10:08:24 +0200 Subject: [PATCH 07/12] Update Sidenav.test.tsx.snap --- .../sidenav/__test__/__snapshots__/Sidenav.test.tsx.snap | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/navigation/sidenav/__test__/__snapshots__/Sidenav.test.tsx.snap b/src/components/navigation/sidenav/__test__/__snapshots__/Sidenav.test.tsx.snap index b944554..d6c9c90 100644 --- a/src/components/navigation/sidenav/__test__/__snapshots__/Sidenav.test.tsx.snap +++ b/src/components/navigation/sidenav/__test__/__snapshots__/Sidenav.test.tsx.snap @@ -930,7 +930,7 @@ exports[`Sidenav Component > should match snapshot with storage 1`] = ` class="flex flex-col" >
should match snapshot with storage 1`] = ` class="flex w-full h-1.5 bg-gray-10 rounded-full" >
From 103ae17185d1543893b5cd1df86dea4bd63a1066 Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Thu, 18 Jun 2026 10:19:14 +0200 Subject: [PATCH 08/12] Update package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 998c81c..e21d8d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@internxt/ui", - "version": "0.1.21", + "version": "0.1.22", "description": "Library of Internxt components", "repository": { "type": "git", From 9aeb41aca71139f762188fb966c117a1199376c3 Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Thu, 18 Jun 2026 12:09:09 +0200 Subject: [PATCH 09/12] update review --- .../usageBanner/UsageWarningBanner.tsx | 24 +------------------ .../__test__/UsageWarningBanner.test.tsx | 21 +++++++++------- .../notifications/usageBanner/utils.tsx | 20 ++++++++++++++++ .../navigation/sidenav/SidenavStorage.tsx | 24 +++++++------------ src/utils/storage.ts | 9 +++++++ 5 files changed, 52 insertions(+), 46 deletions(-) create mode 100644 src/components/feedback/notifications/usageBanner/utils.tsx create mode 100644 src/utils/storage.ts diff --git a/src/components/feedback/notifications/usageBanner/UsageWarningBanner.tsx b/src/components/feedback/notifications/usageBanner/UsageWarningBanner.tsx index 6f966e6..dce2c45 100644 --- a/src/components/feedback/notifications/usageBanner/UsageWarningBanner.tsx +++ b/src/components/feedback/notifications/usageBanner/UsageWarningBanner.tsx @@ -1,6 +1,7 @@ import { CloudWarningIcon, XIcon } from '@phosphor-icons/react'; import React from 'react'; import { Button } from '../../../input/button'; +import { getStorageLevel, renderWithBold, STORAGE_LEVEL_STYLES } from './utils'; export interface UsageWarningBannerProps { usage: string; @@ -17,29 +18,6 @@ export interface UsageWarningBannerProps { darkMode?: boolean; } -export type StorageLevel = 'low warning' | 'middle warning' | 'high warning'; - -export const getStorageLevel = (percentage: number): StorageLevel => { - if (percentage >= 95) return 'high warning'; - if (percentage >= 80) return 'middle warning'; - return 'low warning'; -}; - -const STORAGE_LEVEL_STYLES: Record = { - 'low warning': { bar: 'bg-yellow-60' }, - 'middle warning': { bar: 'bg-orange-60' }, - 'high warning': { bar: 'bg-danger' }, -}; - - -const renderWithBold = (text: string): React.ReactNode => - text.split(/(\*\*[^*]+\*\*)/g).map((segment, index) => { - if (segment.startsWith('**') && segment.endsWith('**')) { - return {segment.slice(2, -2)}; - } - return {segment}; - }); - const UsageWarningBanner: React.FC = ({ usage, limit, diff --git a/src/components/feedback/notifications/usageBanner/__test__/UsageWarningBanner.test.tsx b/src/components/feedback/notifications/usageBanner/__test__/UsageWarningBanner.test.tsx index e6284f0..a600fb8 100644 --- a/src/components/feedback/notifications/usageBanner/__test__/UsageWarningBanner.test.tsx +++ b/src/components/feedback/notifications/usageBanner/__test__/UsageWarningBanner.test.tsx @@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; import { describe, expect, it, vi } from 'vitest'; import { UsageWarningBanner, UsageWarningBannerProps } from '../'; -import { getStorageLevel } from '../UsageWarningBanner'; +import { getStorageLevel } from '../utils'; const renderBanner = (overrides: Partial = {}) => { const props: UsageWarningBannerProps = { @@ -91,17 +91,22 @@ describe('UsageWarningBanner', () => { describe('getStorageLevel', () => { it('flags critical usage from ninety-five percent upwards', () => { - expect(getStorageLevel(95)).toBe('high warning'); - expect(getStorageLevel(100)).toBe('high warning'); + expect(getStorageLevel(95)).toBe('highWarning'); + expect(getStorageLevel(100)).toBe('highWarning'); }); it('flags near-full usage between eighty and ninety-five percent', () => { - expect(getStorageLevel(80)).toBe('middle warning'); - expect(getStorageLevel(94)).toBe('middle warning'); + expect(getStorageLevel(80)).toBe('middleWarning'); + expect(getStorageLevel(94)).toBe('middleWarning'); }); - it('keeps usage below eighty percent as a low warning', () => { - expect(getStorageLevel(0)).toBe('low warning'); - expect(getStorageLevel(79)).toBe('low warning'); + it('flags moderate usage between sixty and eighty percent as a low warning', () => { + expect(getStorageLevel(60)).toBe('lowWarning'); + expect(getStorageLevel(79)).toBe('lowWarning'); + }); + + it('keeps usage below sixty percent as normal', () => { + expect(getStorageLevel(0)).toBe('normal'); + expect(getStorageLevel(59)).toBe('normal'); }); }); diff --git a/src/components/feedback/notifications/usageBanner/utils.tsx b/src/components/feedback/notifications/usageBanner/utils.tsx new file mode 100644 index 0000000..a426147 --- /dev/null +++ b/src/components/feedback/notifications/usageBanner/utils.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { StorageLevel } from '../../../../utils/storage'; + +export { getStorageLevel } from '../../../../utils/storage'; +export type { StorageLevel } from '../../../../utils/storage'; + +export const STORAGE_LEVEL_STYLES: Record = { + normal: { bar: 'bg-yellow-60' }, + 'lowWarning': { bar: 'bg-yellow-60' }, + 'middleWarning': { bar: 'bg-orange-60' }, + 'highWarning': { bar: 'bg-danger' }, +}; + +export const renderWithBold = (text: string): React.ReactNode => + text.split(/(\*\*[^*]+\*\*)/g).map((segment, index) => { + if (segment.startsWith('**') && segment.endsWith('**')) { + return {segment.slice(2, -2)}; + } + return {segment}; + }); diff --git a/src/components/navigation/sidenav/SidenavStorage.tsx b/src/components/navigation/sidenav/SidenavStorage.tsx index 271aec6..696e37d 100644 --- a/src/components/navigation/sidenav/SidenavStorage.tsx +++ b/src/components/navigation/sidenav/SidenavStorage.tsx @@ -1,4 +1,5 @@ import { CloudWarningIcon } from '@phosphor-icons/react'; +import { getStorageLevel, StorageLevel } from '../../../utils/storage'; interface SidenavStorageProps { usage: string; limit: string; @@ -9,20 +10,11 @@ interface SidenavStorageProps { advertisementMessage?: string; } -type StorageLevel = 'normal' | 'low warning' | 'middle warning' | 'high warning'; - -const getStorageLevel = (percentage: number): StorageLevel => { - if (percentage >= 95) return 'high warning'; - if (percentage >= 80) return 'middle warning'; - if (percentage >= 60) return 'low warning'; - return 'normal'; -}; - const STORAGE_LEVEL_STYLES: Record = { normal: { bar: 'bg-gray-60', container: '' }, - 'low warning': { bar: 'bg-yellow-60', container: '' }, - 'middle warning': { bar: 'bg-orange-60', container: '' }, - 'high warning': { bar: 'bg-danger', container: 'bg-alert rounded-xl border border-alert-dark p-3 gap-2' }, + 'lowWarning': { bar: 'bg-yellow-60', container: '' }, + 'middleWarning': { bar: 'bg-orange-60', container: '' }, + 'highWarning': { bar: 'bg-danger', container: 'bg-alert rounded-xl border border-alert-dark p-3 gap-2' }, }; const SidenavStorage = ({ @@ -37,16 +29,18 @@ const SidenavStorage = ({ const level = getStorageLevel(percentage); const styles = STORAGE_LEVEL_STYLES[level]; - const showWarning = level === 'middle warning' || level === 'high warning'; + const showWarning = level === 'middleWarning' || level === 'highWarning'; + const shouldAdvertiseUser = advertisementMessage && showWarning; + return (
- {showWarning && advertisementMessage && ( + {shouldAdvertiseUser && (

{advertisementMessage}

diff --git a/src/utils/storage.ts b/src/utils/storage.ts new file mode 100644 index 0000000..f26dd9f --- /dev/null +++ b/src/utils/storage.ts @@ -0,0 +1,9 @@ +export type StorageLevel = 'normal' | 'lowWarning' | 'middleWarning' | 'highWarning'; + + +export const getStorageLevel = (percentage: number): StorageLevel => { + if (percentage >= 95) return 'highWarning'; + if (percentage >= 80) return 'middleWarning'; + if (percentage >= 60) return 'lowWarning'; + return 'normal'; +}; From 8f097718988b815cca48f8b2c5f1f3f832e3bd08 Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Thu, 18 Jun 2026 14:02:43 +0200 Subject: [PATCH 10/12] refactor(usageBanner): make storage components presentational --- .../usageBanner/UsageWarningBanner.tsx | 110 ++++++++---------- .../__test__/UsageWarningBanner.test.tsx | 56 +++------ .../notifications/usageBanner/utils.tsx | 20 ---- src/components/navigation/sidenav/Sidenav.tsx | 6 + .../navigation/sidenav/SidenavStorage.tsx | 107 ++++++----------- .../__snapshots__/Sidenav.test.tsx.snap | 4 +- .../sidenav/SidenavStorage.stories.tsx | 9 ++ .../components/usageBanner/Banner.stories.tsx | 46 ++++++-- src/utils/storage.ts | 9 -- 9 files changed, 153 insertions(+), 214 deletions(-) delete mode 100644 src/components/feedback/notifications/usageBanner/utils.tsx delete mode 100644 src/utils/storage.ts diff --git a/src/components/feedback/notifications/usageBanner/UsageWarningBanner.tsx b/src/components/feedback/notifications/usageBanner/UsageWarningBanner.tsx index dce2c45..70ab27c 100644 --- a/src/components/feedback/notifications/usageBanner/UsageWarningBanner.tsx +++ b/src/components/feedback/notifications/usageBanner/UsageWarningBanner.tsx @@ -1,86 +1,76 @@ import { CloudWarningIcon, XIcon } from '@phosphor-icons/react'; -import React from 'react'; +import React, { ReactNode } from 'react'; import { Button } from '../../../input/button'; -import { getStorageLevel, renderWithBold, STORAGE_LEVEL_STYLES } from './utils'; export interface UsageWarningBannerProps { + title: string; + description: ReactNode; usage: string; limit: string; percentage: number; - titleLabel: string; - descriptionLabelLine1: string; - descriptionLabelLine2: string; upgradeLabel: string; - closeButtonLabel?: string; + closeButtonLabel: string; onUpgradeClick: () => void; onCloseButtonClick: () => void; + barClassName?: string; isLoading?: boolean; - darkMode?: boolean; } const UsageWarningBanner: React.FC = ({ + title, + description, usage, limit, percentage, - titleLabel, - descriptionLabelLine1, - descriptionLabelLine2, upgradeLabel, - closeButtonLabel = 'Close', + closeButtonLabel, onUpgradeClick, onCloseButtonClick, - isLoading = true, -}) => { - const level = getStorageLevel(percentage); - const styles = STORAGE_LEVEL_STYLES[level]; - - return ( -
-
-
- - -

{titleLabel}

-
- -
- -

{renderWithBold(descriptionLabelLine1)}

-

{renderWithBold(descriptionLabelLine2)}

+ barClassName = 'bg-yellow-60', + isLoading = false, +}) => ( +
+
+
+ + +

{title}

+
-
-
-
-
-
- {isLoading ? ( -
-
-
-
-
- ) : ( - -

{usage}

-

/

-

{limit}

-
- )} +
{description}
+
+
+
+
+
- + {isLoading ? ( +
+
+
+
+
+ ) : ( + +

{usage}

+

/

+

{limit}

+
+ )}
+
- ); -}; +
+); -export default UsageWarningBanner; \ No newline at end of file +export default UsageWarningBanner; diff --git a/src/components/feedback/notifications/usageBanner/__test__/UsageWarningBanner.test.tsx b/src/components/feedback/notifications/usageBanner/__test__/UsageWarningBanner.test.tsx index a600fb8..6176ee9 100644 --- a/src/components/feedback/notifications/usageBanner/__test__/UsageWarningBanner.test.tsx +++ b/src/components/feedback/notifications/usageBanner/__test__/UsageWarningBanner.test.tsx @@ -2,16 +2,14 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; import { describe, expect, it, vi } from 'vitest'; import { UsageWarningBanner, UsageWarningBannerProps } from '../'; -import { getStorageLevel } from '../utils'; const renderBanner = (overrides: Partial = {}) => { const props: UsageWarningBannerProps = { + title: 'Storage almost full', + description: 'You are running out of space', usage: '10 GB', limit: '20 GB', percentage: 50, - titleLabel: 'Storage almost full', - descriptionLabelLine1: 'You are running out of space', - descriptionLabelLine2: 'Upgrade to keep saving files', upgradeLabel: 'Upgrade now', closeButtonLabel: 'Close', onUpgradeClick: vi.fn(), @@ -24,33 +22,27 @@ const renderBanner = (overrides: Partial = {}) => { }; describe('UsageWarningBanner', () => { - it('warns with a danger-coloured bar when storage is critically full', () => { - const { container } = renderBanner({ percentage: 96 }); + it('colours the progress bar with the style chosen by the consumer', () => { + const { container } = renderBanner({ barClassName: 'bg-danger' }); expect(container.querySelector('.bg-danger')).toBeTruthy(); }); - it('warns with an orange bar when storage is nearly full', () => { - const { container } = renderBanner({ percentage: 85 }); - - expect(container.querySelector('.bg-orange-60')).toBeTruthy(); - }); - - it('warns with a yellow bar when storage still has room', () => { - const { container } = renderBanner({ percentage: 40 }); - - expect(container.querySelector('.bg-yellow-60')).toBeTruthy(); - }); - it('fills the bar in proportion to the storage used', () => { - const { container } = renderBanner({ percentage: 73 }); + const { container } = renderBanner({ barClassName: 'bg-yellow-60', percentage: 73 }); const bar = container.querySelector('.bg-yellow-60'); expect(bar?.style.width).toBe('73%'); }); - it('emphasises the portions of the description wrapped in double asterisks', () => { - renderBanner({ descriptionLabelLine1: 'You have used **90%** of your storage' }); + it('renders the rich description provided by the consumer', () => { + renderBanner({ + description: ( +

+ You have used 90% of your storage +

+ ), + }); expect(screen.getByText('90%').tagName).toBe('STRONG'); }); @@ -88,25 +80,3 @@ describe('UsageWarningBanner', () => { expect(onCloseButtonClick).toHaveBeenCalledOnce(); }); }); - -describe('getStorageLevel', () => { - it('flags critical usage from ninety-five percent upwards', () => { - expect(getStorageLevel(95)).toBe('highWarning'); - expect(getStorageLevel(100)).toBe('highWarning'); - }); - - it('flags near-full usage between eighty and ninety-five percent', () => { - expect(getStorageLevel(80)).toBe('middleWarning'); - expect(getStorageLevel(94)).toBe('middleWarning'); - }); - - it('flags moderate usage between sixty and eighty percent as a low warning', () => { - expect(getStorageLevel(60)).toBe('lowWarning'); - expect(getStorageLevel(79)).toBe('lowWarning'); - }); - - it('keeps usage below sixty percent as normal', () => { - expect(getStorageLevel(0)).toBe('normal'); - expect(getStorageLevel(59)).toBe('normal'); - }); -}); diff --git a/src/components/feedback/notifications/usageBanner/utils.tsx b/src/components/feedback/notifications/usageBanner/utils.tsx deleted file mode 100644 index a426147..0000000 --- a/src/components/feedback/notifications/usageBanner/utils.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; -import { StorageLevel } from '../../../../utils/storage'; - -export { getStorageLevel } from '../../../../utils/storage'; -export type { StorageLevel } from '../../../../utils/storage'; - -export const STORAGE_LEVEL_STYLES: Record = { - normal: { bar: 'bg-yellow-60' }, - 'lowWarning': { bar: 'bg-yellow-60' }, - 'middleWarning': { bar: 'bg-orange-60' }, - 'highWarning': { bar: 'bg-danger' }, -}; - -export const renderWithBold = (text: string): React.ReactNode => - text.split(/(\*\*[^*]+\*\*)/g).map((segment, index) => { - if (segment.startsWith('**') && segment.endsWith('**')) { - return {segment.slice(2, -2)}; - } - return {segment}; - }); diff --git a/src/components/navigation/sidenav/Sidenav.tsx b/src/components/navigation/sidenav/Sidenav.tsx index 68f8c89..855861c 100644 --- a/src/components/navigation/sidenav/Sidenav.tsx +++ b/src/components/navigation/sidenav/Sidenav.tsx @@ -18,6 +18,9 @@ export interface SidenavStorageProps { onUpgradeClick: () => void; upgradeLabel?: string; isLoading?: boolean; + barClassName?: string; + containerClassName?: string; + advertisement?: ReactNode; } export interface SidenavProps { @@ -145,6 +148,9 @@ const Sidenav = ({ onUpgradeClick={storage.onUpgradeClick} upgradeLabel={storage.upgradeLabel} isLoading={storage.isLoading} + barClassName={storage.barClassName} + containerClassName={storage.containerClassName} + advertisement={storage.advertisement} /> )}
diff --git a/src/components/navigation/sidenav/SidenavStorage.tsx b/src/components/navigation/sidenav/SidenavStorage.tsx index 696e37d..a49a9d7 100644 --- a/src/components/navigation/sidenav/SidenavStorage.tsx +++ b/src/components/navigation/sidenav/SidenavStorage.tsx @@ -1,21 +1,4 @@ -import { CloudWarningIcon } from '@phosphor-icons/react'; -import { getStorageLevel, StorageLevel } from '../../../utils/storage'; -interface SidenavStorageProps { - usage: string; - limit: string; - percentage: number; - onUpgradeClick: () => void; - upgradeLabel?: string; - isLoading?: boolean; - advertisementMessage?: string; -} - -const STORAGE_LEVEL_STYLES: Record = { - normal: { bar: 'bg-gray-60', container: '' }, - 'lowWarning': { bar: 'bg-yellow-60', container: '' }, - 'middleWarning': { bar: 'bg-orange-60', container: '' }, - 'highWarning': { bar: 'bg-danger', container: 'bg-alert rounded-xl border border-alert-dark p-3 gap-2' }, -}; +import { SidenavStorageProps } from './Sidenav'; const SidenavStorage = ({ usage, @@ -23,60 +6,44 @@ const SidenavStorage = ({ percentage, onUpgradeClick, upgradeLabel, - isLoading = true, - advertisementMessage, -}: SidenavStorageProps): JSX.Element => { - - const level = getStorageLevel(percentage); - const styles = STORAGE_LEVEL_STYLES[level]; - const showWarning = level === 'middleWarning' || level === 'highWarning'; - const shouldAdvertiseUser = advertisementMessage && showWarning; - - - return ( - -
- {shouldAdvertiseUser && ( -
- -

{advertisementMessage}

-
- )} -
-
- {isLoading ? ( -
-
-
-
-
- ) : ( - <> -

{usage}

-

/

-

{limit}

- - )} -
- {upgradeLabel && ( - + isLoading = false, + barClassName = 'bg-gray-60', + containerClassName, + advertisement, +}: SidenavStorageProps): JSX.Element => ( +
+ {advertisement} +
+
+ {isLoading ? ( +
+
+
+
+
+ ) : ( + <> +

{usage}

+

/

+

{limit}

+ )}
-
-
-
+ {upgradeLabel && ( + + )} +
+
+
- ); -}; +
+); export default SidenavStorage; diff --git a/src/components/navigation/sidenav/__test__/__snapshots__/Sidenav.test.tsx.snap b/src/components/navigation/sidenav/__test__/__snapshots__/Sidenav.test.tsx.snap index d6c9c90..c135431 100644 --- a/src/components/navigation/sidenav/__test__/__snapshots__/Sidenav.test.tsx.snap +++ b/src/components/navigation/sidenav/__test__/__snapshots__/Sidenav.test.tsx.snap @@ -930,7 +930,7 @@ exports[`Sidenav Component > should match snapshot with storage 1`] = ` class="flex flex-col" >
should match snapshot with storage 1`] = ` class="flex w-full h-1.5 bg-gray-10 rounded-full" >
diff --git a/src/stories/components/sidenav/SidenavStorage.stories.tsx b/src/stories/components/sidenav/SidenavStorage.stories.tsx index 67adf30..e3d77d5 100644 --- a/src/stories/components/sidenav/SidenavStorage.stories.tsx +++ b/src/stories/components/sidenav/SidenavStorage.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; +import { CloudWarningIcon } from '@phosphor-icons/react'; import { SidenavStorage, SidenavStorageProps } from '@/components/sidenav'; const meta: Meta = { @@ -51,6 +52,14 @@ export const HighUsage: Story = { limit: '10 GB', percentage: 95, upgradeLabel: 'Upgrade now', + barClassName: 'bg-danger', + containerClassName: 'bg-alert rounded-xl border border-alert-dark gap-2', + advertisement: ( +
+ +

Buy space 85% off

+
+ ), }, }; diff --git a/src/stories/components/usageBanner/Banner.stories.tsx b/src/stories/components/usageBanner/Banner.stories.tsx index d0fb0e0..399e79e 100644 --- a/src/stories/components/usageBanner/Banner.stories.tsx +++ b/src/stories/components/usageBanner/Banner.stories.tsx @@ -20,6 +20,7 @@ const meta: Meta = { args: { onUpgradeClick: fn(), onCloseButtonClick: fn(), + closeButtonLabel: 'Close', }, }; @@ -31,9 +32,16 @@ export const Default: Story = { usage: '600MB', limit: '1GB', percentage: 60, - titleLabel: 'Get more space for your files', - descriptionLabelLine1: 'Unlock additional storage with an exclusive 85% discount on your upgrade', - descriptionLabelLine2: 'Access advanced features like file version history, Rclone, NAS support, premium support, and more', + barClassName: 'bg-yellow-60', + title: 'Get more space for your files', + description: ( + <> +

+ Unlock additional storage with an exclusive 85% discount on your upgrade +

+

Access advanced features like file version history, Rclone, NAS support, premium support, and more

+ + ), upgradeLabel: 'Get offer', isLoading: false, }, @@ -45,9 +53,19 @@ export const LowUsage: Story = { usage: '800MB', limit: '1GB', percentage: 80, - titleLabel: 'Your storage is filling up', - descriptionLabelLine1: 'Upgrade today with an exclusive 85% discount and keep uploading without interruptions', - descriptionLabelLine2: 'Get more storage plus advanced features like file version history, NAS support, Rclone integration, and premium support', + barClassName: 'bg-orange-60', + title: 'Your storage is filling up', + description: ( + <> +

+ Upgrade today with an exclusive 85% discount and keep uploading without interruptions +

+

+ Get more storage plus advanced features like file version history, NAS support, Rclone integration, and + premium support +

+ + ), }, }; @@ -57,9 +75,17 @@ export const AlmostFull: Story = { usage: '950MB', limit: '1GB', percentage: 95, - titleLabel: 'Your storage is almost full', - descriptionLabelLine1: 'You may soon be unable to upload new files', - descriptionLabelLine2: 'Upgrade now with an exclusive 85% discount to continue storing and syncing your files without limits' + barClassName: 'bg-danger', + title: 'Your storage is almost full', + description: ( + <> +

You may soon be unable to upload new files

+

+ Upgrade now with an exclusive 85% discount to continue storing and syncing your files + without limits +

+ + ), }, }; @@ -68,4 +94,4 @@ export const Loading: Story = { ...Default.args, isLoading: true, }, -}; \ No newline at end of file +}; diff --git a/src/utils/storage.ts b/src/utils/storage.ts deleted file mode 100644 index f26dd9f..0000000 --- a/src/utils/storage.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type StorageLevel = 'normal' | 'lowWarning' | 'middleWarning' | 'highWarning'; - - -export const getStorageLevel = (percentage: number): StorageLevel => { - if (percentage >= 95) return 'highWarning'; - if (percentage >= 80) return 'middleWarning'; - if (percentage >= 60) return 'lowWarning'; - return 'normal'; -}; From 039419bd53d62fa94930c69999d0bbc0ba8d3d98 Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Thu, 18 Jun 2026 16:27:03 +0200 Subject: [PATCH 11/12] feat: create & use Skeleton component --- .../usageBanner/UsageWarningBanner.tsx | 7 ++----- src/components/feedback/skeleton/Skeleton.tsx | 14 ++++++++++++++ src/components/feedback/skeleton/SkeletonItem.tsx | 9 +++++++++ src/components/feedback/skeleton/index.ts | 3 +++ src/components/index.ts | 1 + .../navigation/sidenav/SidenavStorage.tsx | 7 ++----- 6 files changed, 31 insertions(+), 10 deletions(-) create mode 100644 src/components/feedback/skeleton/Skeleton.tsx create mode 100644 src/components/feedback/skeleton/SkeletonItem.tsx create mode 100644 src/components/feedback/skeleton/index.ts diff --git a/src/components/feedback/notifications/usageBanner/UsageWarningBanner.tsx b/src/components/feedback/notifications/usageBanner/UsageWarningBanner.tsx index 70ab27c..e1e89f6 100644 --- a/src/components/feedback/notifications/usageBanner/UsageWarningBanner.tsx +++ b/src/components/feedback/notifications/usageBanner/UsageWarningBanner.tsx @@ -1,6 +1,7 @@ import { CloudWarningIcon, XIcon } from '@phosphor-icons/react'; import React, { ReactNode } from 'react'; import { Button } from '../../../input/button'; +import Skeleton from '../../skeleton/Skeleton'; export interface UsageWarningBannerProps { title: string; @@ -53,11 +54,7 @@ const UsageWarningBanner: React.FC = ({
{isLoading ? ( -
-
-
-
-
+ ) : (

{usage}

diff --git a/src/components/feedback/skeleton/Skeleton.tsx b/src/components/feedback/skeleton/Skeleton.tsx new file mode 100644 index 0000000..abeb415 --- /dev/null +++ b/src/components/feedback/skeleton/Skeleton.tsx @@ -0,0 +1,14 @@ +import SkeletonItem from './SkeletonItem'; + +/** + * Loading placeholder for the "usage / limit" amount (e.g. "8GB / 100GB"). + */ +const Skeleton = (): React.ReactElement => ( +
+ + + +
+); + +export default Skeleton; diff --git a/src/components/feedback/skeleton/SkeletonItem.tsx b/src/components/feedback/skeleton/SkeletonItem.tsx new file mode 100644 index 0000000..0ce58ab --- /dev/null +++ b/src/components/feedback/skeleton/SkeletonItem.tsx @@ -0,0 +1,9 @@ +export interface SkeletonItemProps { + className?: string; +} + +const SkeletonItem = ({ className }: SkeletonItemProps): React.ReactElement => ( +
+); + +export default SkeletonItem; diff --git a/src/components/feedback/skeleton/index.ts b/src/components/feedback/skeleton/index.ts new file mode 100644 index 0000000..9bd49b7 --- /dev/null +++ b/src/components/feedback/skeleton/index.ts @@ -0,0 +1,3 @@ +export { default as SkeletonItem } from './SkeletonItem'; +export type { SkeletonItemProps } from './SkeletonItem'; +export { default as Skeleton } from './Skeleton'; diff --git a/src/components/index.ts b/src/components/index.ts index 98376dd..def516b 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -7,6 +7,7 @@ export * from './data-display/table/Table'; // feedback export * from './feedback/empty'; export * from './feedback/loader'; +export * from './feedback/skeleton'; export * from './feedback/skeletonLoader'; export * from './feedback/notifications/usageBanner'; diff --git a/src/components/navigation/sidenav/SidenavStorage.tsx b/src/components/navigation/sidenav/SidenavStorage.tsx index a49a9d7..20819af 100644 --- a/src/components/navigation/sidenav/SidenavStorage.tsx +++ b/src/components/navigation/sidenav/SidenavStorage.tsx @@ -1,3 +1,4 @@ +import Skeleton from '../../feedback/skeleton/Skeleton'; import { SidenavStorageProps } from './Sidenav'; const SidenavStorage = ({ @@ -16,11 +17,7 @@ const SidenavStorage = ({
{isLoading ? ( -
-
-
-
-
+ ) : ( <>

{usage}

From 8a4fe1708b34ef5fb9d6e52061430fbc9584e5a2 Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Fri, 19 Jun 2026 08:38:57 +0200 Subject: [PATCH 12/12] feat: skeleton --- .../skeleton/__test__/Skeleton.test.tsx | 19 ++++ .../skeleton/__test__/SkeletonItem.test.tsx | 30 +++++++ .../__snapshots__/Skeleton.test.tsx.snap | 90 +++++++++++++++++++ .../__snapshots__/SkeletonItem.test.tsx.snap | 70 +++++++++++++++ 4 files changed, 209 insertions(+) create mode 100644 src/components/feedback/skeleton/__test__/Skeleton.test.tsx create mode 100644 src/components/feedback/skeleton/__test__/SkeletonItem.test.tsx create mode 100644 src/components/feedback/skeleton/__test__/__snapshots__/Skeleton.test.tsx.snap create mode 100644 src/components/feedback/skeleton/__test__/__snapshots__/SkeletonItem.test.tsx.snap diff --git a/src/components/feedback/skeleton/__test__/Skeleton.test.tsx b/src/components/feedback/skeleton/__test__/Skeleton.test.tsx new file mode 100644 index 0000000..6800bea --- /dev/null +++ b/src/components/feedback/skeleton/__test__/Skeleton.test.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { Skeleton } from '../'; + +describe('Skeleton', () => { + it('should match snapshot', () => { + const skeleton = render(); + expect(skeleton).toMatchSnapshot(); + }); + + it('should render three skeleton items', () => { + const { container } = render(); + + const items = container.querySelectorAll('.animate-pulse'); + expect(items).toHaveLength(3); + }); +}); diff --git a/src/components/feedback/skeleton/__test__/SkeletonItem.test.tsx b/src/components/feedback/skeleton/__test__/SkeletonItem.test.tsx new file mode 100644 index 0000000..3899216 --- /dev/null +++ b/src/components/feedback/skeleton/__test__/SkeletonItem.test.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { SkeletonItem } from '../'; + +describe('SkeletonItem', () => { + it('should match snapshot', () => { + const skeleton = render(); + expect(skeleton).toMatchSnapshot(); + }); + + it('should render a pulsing placeholder', () => { + const { container } = render(); + + const item = container.firstChild as HTMLElement; + expect(item).toHaveClass('animate-pulse'); + expect(item).toHaveClass('rounded-lg'); + expect(item).toHaveClass('bg-gray-5'); + }); + + it('should append the provided className', () => { + const { container } = render(); + + const item = container.firstChild as HTMLElement; + expect(item).toHaveClass('h-3'); + expect(item).toHaveClass('w-8'); + expect(item).toHaveClass('animate-pulse'); + }); +}); diff --git a/src/components/feedback/skeleton/__test__/__snapshots__/Skeleton.test.tsx.snap b/src/components/feedback/skeleton/__test__/__snapshots__/Skeleton.test.tsx.snap new file mode 100644 index 0000000..f941e05 --- /dev/null +++ b/src/components/feedback/skeleton/__test__/__snapshots__/Skeleton.test.tsx.snap @@ -0,0 +1,90 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Skeleton > should match snapshot 1`] = ` +{ + "asFragment": [Function], + "baseElement": +
+
+
+
+
+
+
+ , + "container":
+
+
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/src/components/feedback/skeleton/__test__/__snapshots__/SkeletonItem.test.tsx.snap b/src/components/feedback/skeleton/__test__/__snapshots__/SkeletonItem.test.tsx.snap new file mode 100644 index 0000000..78997dd --- /dev/null +++ b/src/components/feedback/skeleton/__test__/__snapshots__/SkeletonItem.test.tsx.snap @@ -0,0 +1,70 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`SkeletonItem > should match snapshot 1`] = ` +{ + "asFragment": [Function], + "baseElement": +
+
+
+ , + "container":
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`;