From 00e61229060c365b33318065edaabf0d6ab8a484 Mon Sep 17 00:00:00 2001 From: foxhoundn Date: Wed, 6 May 2026 13:53:01 +0200 Subject: [PATCH 1/2] feat: add ReqoreSeverityRow component with customizable features and styles - Implemented ReqoreSeverityRow component for displaying severity information with labels, descriptions, badges, and actions. - Added support for various props including intent, transparency, and rounded corners. - Integrated with ReqoreTooltip for hover effects and tooltips. - Updated ReqoreProvider and UIProvider to support glowing icons feature. - Created stories for ReqoreSeverityRow showcasing different use cases and configurations. - Refactored existing stories for consistency and clarity in display categories. --- __tests__/Callout.test.tsx | 229 ++++++++++++ __tests__/EntityRow.test.tsx | 310 ++++++++++++++++ __tests__/FeatureCard.test.tsx | 177 +++++++++ __tests__/SeverityRow.test.tsx | 332 +++++++++++++++++ __tests__/icon.test.tsx | 111 ++++++ __tests__/progress.test.tsx | 87 +++++ package.json | 2 +- src/components/COMPONENTS.md | 8 +- src/components/Callout/index.tsx | 179 ++++++++- src/components/EntityRow/index.tsx | 344 ++++++++++++++++++ src/components/FeatureCard/index.tsx | 122 +++++-- src/components/Icon/index.tsx | 61 +++- src/components/Progress/index.tsx | 83 +++++ src/components/SeverityRow/index.tsx | 284 +++++++++++++++ src/containers/ReqoreProvider.tsx | 2 + src/containers/UIProvider.tsx | 1 + src/context/ReqoreContext.tsx | 7 + src/index.tsx | 2 + src/stories/Callout/Callout.stories.tsx | 147 ++++++++ src/stories/EmptyState/EmptyState.stories.tsx | 2 +- src/stories/EntityRow/EntityRow.stories.tsx | 214 +++++++++++ .../FeatureCard/FeatureCard.stories.tsx | 133 ++++++- src/stories/Icon/Icon.stories.tsx | 47 ++- src/stories/Progress/Progress.stories.tsx | 48 +++ .../SeverityRow/SeverityRow.stories.tsx | 201 ++++++++++ src/stories/Statistic/Statistic.stories.tsx | 2 +- 26 files changed, 3075 insertions(+), 60 deletions(-) create mode 100644 __tests__/EntityRow.test.tsx create mode 100644 __tests__/SeverityRow.test.tsx create mode 100644 src/components/EntityRow/index.tsx create mode 100644 src/components/SeverityRow/index.tsx create mode 100644 src/stories/EntityRow/EntityRow.stories.tsx create mode 100644 src/stories/SeverityRow/SeverityRow.stories.tsx diff --git a/__tests__/Callout.test.tsx b/__tests__/Callout.test.tsx index c395eebc..12727c3b 100644 --- a/__tests__/Callout.test.tsx +++ b/__tests__/Callout.test.tsx @@ -68,3 +68,232 @@ test('Renders with container effect', () => { expect(document.querySelectorAll('.reqore-callout').length).toBe(1); expect(document.querySelector('.reqore-callout')!.textContent).toBe('Gradient surface'); }); + +test('Renders with label and description', () => { + render( + + + + + + + + ); + + expect(document.querySelector('.reqore-callout-label')!.textContent).toBe('Heads up'); + expect(document.querySelector('.reqore-callout-description')!.textContent).toBe( + 'Something to look at' + ); +}); + +test('Falls back to children when label/description are not provided', () => { + render( + + + + Plain children content + + + + ); + + expect(document.querySelectorAll('.reqore-callout-content').length).toBe(1); + expect(document.querySelectorAll('.reqore-callout-label').length).toBe(0); +}); + +test('Renders with icon', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-callout-icon').length).toBe(1); +}); + +test('Renders with badge (string)', () => { + render( + + + + + + + + ); + + expect(document.querySelector('.reqore-button-badge')!.textContent).toContain('New'); +}); + +test('Renders with badge array', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-button-badge').length).toBe(2); +}); + +test('Renders with onClose and fires it', () => { + const handleClose = jest.fn(); + + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-callout-close').length).toBe(1); + + fireEvent.click(document.querySelector('.reqore-callout-close')!); + expect(handleClose).toHaveBeenCalledTimes(1); +}); + +test('Close button click does not bubble to onClick', () => { + const handleClick = jest.fn(); + const handleClose = jest.fn(); + + render( + + + + + + + + ); + + fireEvent.click(document.querySelector('.reqore-callout-close')!); + expect(handleClose).toHaveBeenCalledTimes(1); + expect(handleClick).toHaveBeenCalledTimes(0); +}); + +test('Renders with intents', () => { + render( + + + + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-callout').length).toBe(4); +}); + +test('Renders with different sizes', () => { + render( + + + + Tiny + Small + Normal + Big + + + + ); + + expect(document.querySelectorAll('.reqore-callout').length).toBe(4); +}); + +test('Renders bordered with flat={false}', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-callout').length).toBe(1); +}); + +test('Renders with rounded={false}', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-callout').length).toBe(1); +}); + +test('Renders with transparent background', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-callout').length).toBe(1); +}); + +test('Renders with labelEffect / descriptionEffect', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-callout-label').length).toBe(1); + expect(document.querySelectorAll('.reqore-callout-description').length).toBe(1); +}); + +test('Renders disabled', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-callout').length).toBe(1); +}); diff --git a/__tests__/EntityRow.test.tsx b/__tests__/EntityRow.test.tsx new file mode 100644 index 00000000..b01f2790 --- /dev/null +++ b/__tests__/EntityRow.test.tsx @@ -0,0 +1,310 @@ +import { fireEvent, render } from '@testing-library/react'; +import { + ReqoreContent, + ReqoreEntityRow, + ReqoreLayoutContent, + ReqoreUIProvider, +} from '../src'; + +test('Renders with label', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-entity-row').length).toBe(1); + expect(document.querySelector('.reqore-entity-row-label')!.textContent).toContain( + 'Process Incoming Order' + ); +}); + +test('Renders with description and metadata', () => { + render( + + + + + + + + ); + + expect(document.querySelector('.reqore-entity-row-description')!.textContent).toBe( + 'Routes Shopify orders' + ); + expect(document.querySelector('.reqore-entity-row-metadata')!.textContent).toBe( + 'Last run: success · just now' + ); +}); + +test('Renders with icon tile', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-entity-row-icon-tile').length).toBe(1); +}); + +test('Does not render icon tile without icon or image', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-entity-row-icon-tile').length).toBe(0); +}); + +test('Renders with badge (object)', () => { + render( + + + + + + + + ); + + expect(document.querySelector('.reqore-entity-row-label')!.textContent).toContain('Failed'); +}); + +test('Renders with badge array', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-button-badge').length).toBe(2); +}); + +test('Renders with actions', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-entity-row-actions').length).toBe(1); + expect(document.querySelector('.reqore-entity-row-actions')!.textContent).toContain('Run'); +}); + +test('Calls onClick when row is clicked', () => { + const handleClick = jest.fn(); + + render( + + + + + + + + ); + + fireEvent.click(document.querySelector('.reqore-entity-row')!); + expect(handleClick).toHaveBeenCalledTimes(1); +}); + +test('Renders with intents', () => { + render( + + + + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-entity-row').length).toBe(4); +}); + +test('Renders with different sizes', () => { + render( + + + + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-entity-row').length).toBe(4); +}); + +test('Renders bordered with flat={false}', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-entity-row').length).toBe(1); +}); + +test('Renders with rounded={false}', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-entity-row').length).toBe(1); +}); + +test('Renders with transparent background', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-entity-row').length).toBe(1); +}); + +test('Renders with effect / labelEffect / descriptionEffect / metadataEffect', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-entity-row').length).toBe(1); + expect(document.querySelectorAll('.reqore-entity-row-description').length).toBe(1); + expect(document.querySelectorAll('.reqore-entity-row-metadata').length).toBe(1); +}); + +test('Renders with iconImage', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-entity-row-icon-tile').length).toBe(1); +}); + +test('Does not render description/metadata when not provided', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-entity-row-description').length).toBe(0); + expect(document.querySelectorAll('.reqore-entity-row-metadata').length).toBe(0); +}); + +test('Renders with wrap=false (single-line ellipsis)', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-entity-row-description').length).toBe(1); + expect(document.querySelectorAll('.reqore-entity-row-metadata').length).toBe(1); +}); + +test('Renders with wrap=true by default', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-entity-row-description').length).toBe(1); +}); diff --git a/__tests__/FeatureCard.test.tsx b/__tests__/FeatureCard.test.tsx index d5ded4fd..a3ff880d 100644 --- a/__tests__/FeatureCard.test.tsx +++ b/__tests__/FeatureCard.test.tsx @@ -68,3 +68,180 @@ test('Calls onClick handler', () => { fireEvent.click(document.querySelector('.reqore-feature-card')!); expect(handleClick).toHaveBeenCalledTimes(1); }); + +test('Renders with badge (string)', () => { + render( + + + + + + + + ); + + expect(document.querySelector('.reqore-button-badge')!.textContent).toContain('New'); +}); + +test('Renders with badge array', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-button-badge').length).toBe(2); +}); + +test('Renders with intents', () => { + render( + + + + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-feature-card').length).toBe(4); +}); + +test('Renders with different sizes', () => { + render( + + + + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-feature-card').length).toBe(4); +}); + +test('Renders bordered with flat={false}', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-feature-card').length).toBe(1); +}); + +test('Renders with rounded={false}', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-feature-card').length).toBe(1); +}); + +test('Renders with transparent background', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-feature-card').length).toBe(1); +}); + +test('Renders disabled', () => { + render( + + + + {}} /> + + + + ); + + expect(document.querySelectorAll('.reqore-feature-card').length).toBe(1); +}); + +test('Renders with effect / labelEffect / descriptionEffect', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-feature-card').length).toBe(1); + expect(document.querySelectorAll('.reqore-feature-card-description').length).toBe(1); +}); + +test('Renders with wrap=false (single-line ellipsis)', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-feature-card-label').length).toBe(1); + expect(document.querySelectorAll('.reqore-feature-card-description').length).toBe(1); +}); + +test('Auto-detects interactive when onClick is provided', () => { + const handleClick = jest.fn(); + + render( + + + + + + + + ); + + fireEvent.click(document.querySelector('.reqore-feature-card')!); + expect(handleClick).toHaveBeenCalledTimes(1); +}); diff --git a/__tests__/SeverityRow.test.tsx b/__tests__/SeverityRow.test.tsx new file mode 100644 index 00000000..f1b2dccf --- /dev/null +++ b/__tests__/SeverityRow.test.tsx @@ -0,0 +1,332 @@ +import { fireEvent, render } from '@testing-library/react'; +import { + ReqoreContent, + ReqoreLayoutContent, + ReqoreSeverityRow, + ReqoreTag, + ReqoreUIProvider, +} from '../src'; + +test('Renders with label', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-severity-row').length).toBe(1); + expect(document.querySelector('.reqore-severity-row-label')!.textContent).toContain( + 'Critical issue' + ); +}); + +test('Renders with description', () => { + render( + + + + + + + + ); + + expect(document.querySelector('.reqore-severity-row-description')!.textContent).toBe( + 'Threshold exceeded' + ); +}); + +test('Renders with strip by default', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-severity-row-strip').length).toBe(1); +}); + +test('Hides strip when showStrip is false', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-severity-row-strip').length).toBe(0); +}); + +test('Renders with leading content', () => { + render( + + + + } + /> + + + + ); + + expect(document.querySelector('.reqore-severity-row-label')!.textContent).toContain('Critical'); +}); + +test('Renders with badge (string)', () => { + render( + + + + + + + + ); + + expect(document.querySelector('.reqore-button-badge')!.textContent).toContain('3 open'); +}); + +test('Renders with badge array', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-button-badge').length).toBe(2); +}); + +test('Renders with actions', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-severity-row-actions').length).toBe(1); + expect(document.querySelector('.reqore-severity-row-actions')!.textContent).toContain( + 'Investigate' + ); +}); + +test('Calls onClick when row is clicked', () => { + const handleClick = jest.fn(); + + render( + + + + + + + + ); + + fireEvent.click(document.querySelector('.reqore-severity-row')!); + expect(handleClick).toHaveBeenCalledTimes(1); +}); + +test('Renders with intents', () => { + render( + + + + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-severity-row').length).toBe(4); +}); + +test('Renders with different sizes', () => { + render( + + + + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-severity-row').length).toBe(4); +}); + +test('Renders bordered with flat={false}', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-severity-row').length).toBe(1); +}); + +test('Renders with rounded={false}', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-severity-row').length).toBe(1); +}); + +test('Renders with transparent background', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-severity-row').length).toBe(1); +}); + +test('Renders with effect/labelEffect/descriptionEffect', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-severity-row').length).toBe(1); + expect(document.querySelectorAll('.reqore-severity-row-description').length).toBe(1); +}); + +test('Renders disabled', () => { + const handleClick = jest.fn(); + + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-severity-row').length).toBe(1); +}); + +test('Does not render description when not provided', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-severity-row-description').length).toBe(0); +}); + +test('Does not render actions when not provided', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-severity-row-actions').length).toBe(0); +}); + +test('Renders with wrap=false (single-line ellipsis)', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-severity-row-description').length).toBe(1); +}); + +test('Renders with wrap=true by default', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-severity-row-description').length).toBe(1); +}); diff --git a/__tests__/icon.test.tsx b/__tests__/icon.test.tsx index 22501a20..e8131c1d 100644 --- a/__tests__/icon.test.tsx +++ b/__tests__/icon.test.tsx @@ -51,3 +51,114 @@ test('Tooltip on works', () => { expect(document.querySelectorAll('.reqore-popover-content').length).toBe(1); }); + +test('Renders with glow=true (drop-shadow filter)', () => { + render( + + + + + + + + ); + + const wrapper = document.querySelector('.reqore-icon') as HTMLElement; + expect(wrapper).toBeTruthy(); + expect(wrapper.style.filter).toContain('drop-shadow'); +}); + +test('Renders with glow as a color string', () => { + render( + + + + + + + + ); + + const wrapper = document.querySelector('.reqore-icon') as HTMLElement; + expect(wrapper.style.filter).toContain('drop-shadow'); +}); + +test('Renders with glow object (custom blur and opacity)', () => { + render( + + + + + + + + ); + + const wrapper = document.querySelector('.reqore-icon') as HTMLElement; + expect(wrapper.style.filter).toContain('drop-shadow'); + expect(wrapper.style.filter).toContain('16px'); +}); + +test('Does not apply glow filter when glow is not set', () => { + render( + + + + + + + + ); + + const wrapper = document.querySelector('.reqore-icon') as HTMLElement; + expect(wrapper.style.filter).toBe(''); +}); + +test('Applies glow when global glowingIcons option is enabled', () => { + render( + + + + + + + + ); + + const wrapper = document.querySelector('.reqore-icon') as HTMLElement; + expect(wrapper.style.filter).toContain('drop-shadow'); +}); + +test('Local glow=false overrides global glowingIcons', () => { + render( + + + + + + + + ); + + const wrapper = document.querySelector('.reqore-icon') as HTMLElement; + expect(wrapper.style.filter).toBe(''); +}); + +test('Does not apply glow when glowingIcons option is disabled', () => { + render( + + + + + + + + ); + + const wrapper = document.querySelector('.reqore-icon') as HTMLElement; + expect(wrapper.style.filter).toBe(''); +}); diff --git a/__tests__/progress.test.tsx b/__tests__/progress.test.tsx index e8ac0eb2..e7952559 100644 --- a/__tests__/progress.test.tsx +++ b/__tests__/progress.test.tsx @@ -253,3 +253,90 @@ test('Does not show labels section when no label props provided', () => { expect(document.querySelectorAll('.reqore-progress-labels').length).toBe(0); }); + +test('Renders with target marker', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-progress-target').length).toBe(1); + expect(document.querySelectorAll('.reqore-progress-target-label').length).toBe(1); + expect(document.querySelector('.reqore-progress-target-label')!.textContent).toBe('Target 80%'); +}); + +test('Renders with custom target label', () => { + render( + + + + + + + + ); + + expect(document.querySelector('.reqore-progress-target-label')!.textContent).toBe('Goal'); +}); + +test('Hides target label when showTargetLabel is false', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-progress-target').length).toBe(1); + expect(document.querySelectorAll('.reqore-progress-target-label').length).toBe(0); +}); + +test('Does not render target marker when target is not set', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-progress-target').length).toBe(0); +}); + +test('Does not render target marker when indeterminate', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-progress-target').length).toBe(0); +}); + +test('Clamps target value to max', () => { + render( + + + + + + + + ); + + expect(document.querySelector('.reqore-progress-target-label')!.textContent).toBe('Target 100%'); +}); diff --git a/package.json b/package.json index 6e211970..d1acc2b0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qoretechnologies/reqore", - "version": "0.65.0", + "version": "0.66.0", "description": "ReQore is a highly theme-able and modular UI library for React", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/components/COMPONENTS.md b/src/components/COMPONENTS.md index c9d1b04d..1ed62dae 100644 --- a/src/components/COMPONENTS.md +++ b/src/components/COMPONENTS.md @@ -5,6 +5,7 @@ | **ReqoreAccordion** | Expandable/collapsible panels that can display multiple items with optional icons, badges, and callbacks for toggling. | | **ReqoreBreadcrumbs** | Navigation component displaying hierarchical breadcrumb trails with responsive collapse and optional tab support. | | **ReqoreButton** | Primary action button with support for icons, badges, loading states, effects, and multiple style variants (minimal, flat, transparent). | +| **ReqoreCallout** | Inline message surface for inline notices, warnings, and confirmations. Supports a leading icon, optional `label` + `description` (or freeform `children`), badge (TReqoreBadge), accent strip (left/top with configurable size), close button (`onClose` + `closeButtonProps`), and the standard prop set: intent (info/success/warning/danger; controls accent + border + icon colour), size, flat (border on `false`), rounded, fluid, fixed, transparent, customTheme, tooltip, disabled, effect / labelEffect / descriptionEffect / contentEffect, and click handlers. | | **ReqoreCheckbox** | Toggle control that can render as a standard checkbox or switch component with customizable icons and text labels. | | **ReqoreCollection** | Grid/list view component for displaying arrays of items with filtering, sorting, zoom, and pagination capabilities. | | **ReqoreColumns** | Layout component using CSS Grid to arrange content into responsive columns with customizable gaps and alignment. | @@ -17,11 +18,13 @@ | **ReqoreDropdown** | Button-triggered dropdown menu supporting filtering, nested items, multi-select, and keyboard navigation. | | **ReqoreEffect** | Styled component providing visual effects like gradients, glows, filters, and text decorations. | | **ReqoreEmptyState** | Placeholder component displayed when content is unavailable, with optional icon, title, description, and action buttons. | +| **ReqoreEntityRow** | Compact row primitive for lists of items (e.g. automations, drafts, integrations) with a leading icon tile, label/description/metadata stack, badge support (TReqoreBadge), and right-side action buttons. Supports the standard prop set: intent (with tinted backgrounds), size, flat (border on `false`), rounded, fluid, transparent, customTheme, tooltip, disabled, effect / labelEffect / descriptionEffect / metadataEffect, wrap (single-line ellipsis on `false`), and click handlers. | +| **ReqoreFeatureCard** | Highlight card for onboarding flows, feature spotlights, and product steps. Renders an optional marker (line/number/none), label, description, and badge. Supports the standard prop set: intent (controls marker + border colour), size, flat (border on `false`), rounded, fluid, fixed, transparent, customTheme, tooltip, disabled, interactive (auto-detected from `onClick`), effect / labelEffect / descriptionEffect / markerEffect, and wrap (single-line ellipsis on `false`). | | **ReqoreErrorBoundary** | React error boundary that catches errors and displays them with optional fallback UI and reset functionality. | | **ReqoreExportModal** | Modal dialog for exporting data in multiple formats (CSV, JSON, YAML) with copy-to-clipboard functionality. | | **ReqoreModalsWrapper** | Portal wrapper that renders all queued modals and dialogs from the global context. | | **ReqoreHeading** | Typography component for headings (H1-H6) with theme support and text effects. | -| **ReqoreIcon** | Icon rendering component using RemixIcon set with size, color, animation, and tooltip support. | +| **ReqoreIcon** | Icon rendering component using RemixIcon set with size, color, animation, tooltip, and an optional contour-following `glow` (boolean / colour / `{ color, blur, opacity }`) rendered via `filter: drop-shadow`. The global `ReqoreUIProvider` option `glowingIcons: true` opts every icon into glow mode by default; individual icons opt out with `glow={false}`. | | **ReqoreInput** | Text input field with optional icons, clear button, loading states, and comprehensive styling options. | | **ReqoreInputClearButton** | Animated clear button component for input fields that appears on focus. | | **ReqoreInternalPopover** | Internal popover positioning and rendering component using Popper.js for precise placement. | @@ -38,11 +41,12 @@ | **ReqorePanel** | Container component with optional header, footer, actions, and resizable panels with breadcrumbs support. | | **ReqoreP** | Text paragraph component with theme support, effects, and size customization. | | **ReqorePopover** | Popover/tooltip component with multiple trigger handlers (hover, click, focus) and smart positioning. | -| **ReqoreProgress** | Progress bar component with optional animations, indeterminate state, and customizable labels and icons. | +| **ReqoreProgress** | Progress bar component with optional animations, indeterminate state, customizable labels/icons, and an optional target marker for visualising goals (e.g. "90% target coverage"). | | **ReqoreRadioGroup** | Radio button group component allowing single selection from multiple options with optional dividers. | | **ReqoreRating** | Star rating component supporting half-steps, keyboard navigation, and optional clear functionality. | | **ReqoreRichTextEditor** | Rich text editor using Slate framework with support for inline tags and text formatting options. | | **ReqoreSegmentedControl** | Compact button-bar toggle for selecting one of 2-4 exclusive options with a sliding indicator animation. | +| **ReqoreSeverityRow** | Row primitive for issue/alert/anomaly lists with a left severity strip, leading slot for a severity tag, label/description body, badge support (TReqoreBadge), and right-side action buttons. Supports the standard prop set: intent (info/success/warning/danger with tinted backgrounds), size, flat (border on `false`), rounded, fluid, transparent, customTheme, tooltip, disabled, effect / labelEffect / descriptionEffect, wrap (single-line ellipsis on `false`), optional strip-hide, and click handlers. | | **ReqoreSkeleton** | Animated skeleton/placeholder component for loading states with customizable dimensions. | | **ReqoreSlider** | Range slider component supporting single values or ranges with optional labels and customizable styling. | | **ReqoreSpacer** | Flexible spacing component that creates horizontal or vertical gaps with optional dividing lines. | diff --git a/src/components/Callout/index.tsx b/src/components/Callout/index.tsx index d5b28948..1055a227 100644 --- a/src/components/Callout/index.tsx +++ b/src/components/Callout/index.tsx @@ -1,7 +1,7 @@ -import { forwardRef, memo } from 'react'; import { rgba } from 'polished'; +import { forwardRef, memo, useMemo } from 'react'; import styled, { css } from 'styled-components'; -import { PADDING_FROM_SIZE, TEXT_FROM_SIZE } from '../../constants/sizes'; +import { PADDING_FROM_SIZE, RADIUS_FROM_SIZE, TEXT_FROM_SIZE } from '../../constants/sizes'; import { IReqoreTheme } from '../../constants/theme'; import { changeDarkness, @@ -9,6 +9,7 @@ import { getMainBackgroundColor, getReadableColor, } from '../../helpers/colors'; +import { getOneLessSize } from '../../helpers/utils'; import { useReqoreTheme } from '../../hooks/useTheme'; import { DisabledElement } from '../../styles'; import { @@ -22,7 +23,13 @@ import { IWithReqoreSize, IWithReqoreTooltip, } from '../../types/global'; -import { IReqoreEffect, StyledEffect, StyledTextEffect } from '../Effect'; +import { IReqoreIconName } from '../../types/icons'; +import ReqoreButton, { ButtonBadge, IReqoreButtonProps, TReqoreBadge } from '../Button'; +import ReqoreControlGroup from '../ControlGroup'; +import { IReqoreEffect, StyledEffect, StyledTextEffect, TReqoreEffectColor } from '../Effect'; +import ReqoreIcon, { IReqoreIconProps } from '../Icon'; +import { ReqoreP } from '../Paragraph'; +import { ReqoreSpan } from '../Span'; import { ReqoreTooltipComponent } from '../TooltipComponent'; export interface IReqoreCalloutProps @@ -36,21 +43,51 @@ export interface IReqoreCalloutProps IWithReqoreFluid, IWithReqoreSize, IWithReqoreTooltip { + /** Strong primary heading rendered above the body. */ + label?: React.ReactNode; + /** Effect applied to the label. */ + labelEffect?: IReqoreEffect; + /** Body copy rendered under the label. Falls back to `children`. */ + description?: React.ReactNode; + /** Effect applied to the description. */ + descriptionEffect?: IReqoreEffect; + /** Leading icon (typically an info / alert / error glyph). */ + icon?: IReqoreIconName; + /** Color of the leading icon. Defaults to the intent color. */ + iconColor?: TReqoreEffectColor; + /** Additional props passed to the leading ReqoreIcon. */ + iconProps?: Partial; + /** Badge(s) shown next to the label, identical to other Reqore components. */ + badge?: TReqoreBadge | TReqoreBadge[]; + /** Renders a close button on the right edge and fires when clicked. */ + onClose?: () => void; + /** Override props for the close button. */ + closeButtonProps?: Partial; + /** Where the accent strip is rendered. */ accentPosition?: 'left' | 'top'; + /** Thickness of the accent strip in pixels. */ accentSize?: number; + /** Effect applied to the legacy `children` slot. */ contentEffect?: IReqoreEffect; + /** Round the corners. Default `true`. */ rounded?: boolean; + /** Marks the callout as clickable; auto-detected from `onClick`. */ interactive?: boolean; + /** Hide the tinted surface background. */ + transparent?: boolean; } -interface IStyledCalloutProps extends IReqoreCalloutProps { +interface IStyledCalloutProps extends Omit { theme: IReqoreTheme; + $transparent?: boolean; + $hasContent?: boolean; } const StyledCallout = styled(StyledEffect)` position: relative; display: flex; - align-items: center; + align-items: flex-start; + gap: ${({ size = 'normal' }) => PADDING_FROM_SIZE[size] * 2}px; width: ${({ fluid, fixed }) => (fluid && !fixed ? '100%' : undefined)}; max-width: 100%; padding: ${({ size = 'normal', accentPosition = 'left', accentSize = 5 }) => @@ -61,10 +98,17 @@ const StyledCallout = styled(StyledEffect)` : `${PADDING_FROM_SIZE[size] * 3 + accentSize}px ${PADDING_FROM_SIZE[size] * 3}px ${ PADDING_FROM_SIZE[size] * 3 }px`}; - background-color: ${({ theme }) => changeDarkness(getMainBackgroundColor(theme), 0.03)}; - border: ${({ theme, flat }) => - flat ? 0 : `1px solid ${changeLightness(getMainBackgroundColor(theme), 0.08)}`}; - border-radius: ${({ rounded }) => (rounded ? '8px' : 0)}; + background-color: ${({ theme, $transparent }) => + $transparent ? 'transparent' : changeDarkness(getMainBackgroundColor(theme), 0.03)}; + border: ${({ theme, flat, intent }) => + flat + ? 0 + : `1px solid ${changeLightness( + intent ? theme.intents[intent] : getMainBackgroundColor(theme), + 0.08 + )}`}; + border-radius: ${({ rounded, size = 'normal' }) => + rounded === false ? 0 : `${RADIUS_FROM_SIZE[size]}px`}; overflow: hidden; flex: ${({ fluid }) => (fluid ? '1 auto' : '0 0 auto')}; transition: @@ -110,6 +154,22 @@ const StyledCallout = styled(StyledEffect)` : undefined} `; +const StyledCalloutBody = styled.div` + display: flex; + flex-flow: column; + gap: 6px; + flex: 1 1 auto; + min-width: 0; +`; + +const StyledCalloutLabelRow = styled.div` + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + min-width: 0; +`; + const StyledCalloutContent = styled(StyledTextEffect)<{ size: IReqoreCalloutProps['size']; theme: IReqoreTheme; @@ -125,6 +185,16 @@ export const ReqoreCallout = memo( ( { children, + label, + labelEffect, + description, + descriptionEffect, + icon, + iconColor, + iconProps, + badge, + onClose, + closeButtonProps, size = 'normal', customTheme, inheritCustomTheme, @@ -135,7 +205,8 @@ export const ReqoreCallout = memo( fixed, disabled, tooltip, - rounded, + rounded = true, + transparent = false, effect, contentEffect, interactive, @@ -148,6 +219,16 @@ export const ReqoreCallout = memo( ) => { const theme = useReqoreTheme('main', customTheme, undefined, undefined, inheritCustomTheme); const isInteractive = interactive || !!onClick; + const descriptionSize = useMemo(() => getOneLessSize(size), [size]); + const hasBadge = badge !== undefined && badge !== null; + const hasIcon = !!icon || !!iconProps?.image; + const hasStructuredContent = !!label || !!description; + + const resolvedIconColor: TReqoreEffectColor = useMemo(() => { + if (iconColor) return iconColor; + if (intent) return theme.intents[intent] as TReqoreEffectColor; + return getReadableColor(theme, undefined, undefined, true) as TReqoreEffectColor; + }, [iconColor, intent, theme]); return ( - - {children} - + {hasIcon && ( + + )} + {hasStructuredContent ? ( + + {label && ( + + + {label} + + {hasBadge && } + + )} + {description && ( + + {description} + + )} + {!label && hasBadge && } + + ) : ( + + {children} + {hasBadge && ( + + )} + + )} + {onClose && ( + + { + event.stopPropagation(); + closeButtonProps?.onClick?.(event); + onClose(); + }} + className={`reqore-callout-close ${closeButtonProps?.className ?? ''}`.trim()} + /> + + )} ); } diff --git a/src/components/EntityRow/index.tsx b/src/components/EntityRow/index.tsx new file mode 100644 index 00000000..08fb8190 --- /dev/null +++ b/src/components/EntityRow/index.tsx @@ -0,0 +1,344 @@ +import { rgba } from 'polished'; +import { forwardRef, memo, useMemo } from 'react'; +import styled, { css } from 'styled-components'; +import { PADDING_FROM_SIZE, RADIUS_FROM_SIZE, TSizes } from '../../constants/sizes'; +import { IReqoreTheme, TReqoreIntent } from '../../constants/theme'; +import { changeLightness, getMainBackgroundColor, getReadableColor } from '../../helpers/colors'; +import { getOneLessSize } from '../../helpers/utils'; +import { useReqoreTheme } from '../../hooks/useTheme'; +import { DisabledElement } from '../../styles'; +import { + IReqoreDisabled, + IReqoreIntent, + IWithReqoreCustomTheme, + IWithReqoreEffect, + IWithReqoreFlat, + IWithReqoreFluid, + IWithReqoreSize, + IWithReqoreTooltip, +} from '../../types/global'; +import { IReqoreIconName } from '../../types/icons'; +import ReqoreButton, { ButtonBadge, IReqoreButtonProps, TReqoreBadge } from '../Button'; +import ReqoreControlGroup from '../ControlGroup'; +import { IReqoreEffect, StyledEffect, TReqoreEffectColor } from '../Effect'; +import ReqoreIcon, { IReqoreIconProps } from '../Icon'; +import { ReqoreP } from '../Paragraph'; +import { ReqoreSpan } from '../Span'; +import { ReqoreTooltipComponent } from '../TooltipComponent'; + +export interface IReqoreEntityRowAction extends Omit { + /** Visible button label. */ + label?: string; +} + +export interface IReqoreEntityRowProps + extends Omit, 'title'>, + IReqoreDisabled, + IReqoreIntent, + IWithReqoreCustomTheme, + IWithReqoreEffect, + IWithReqoreFlat, + IWithReqoreFluid, + IWithReqoreSize, + IWithReqoreTooltip { + /** Primary text — e.g. the Qog name. */ + label: React.ReactNode; + /** Secondary text — typically a one-line description. */ + description?: React.ReactNode; + /** Tertiary text — e.g. "Last run: success · 3 hours ago · 384ms". */ + metadata?: React.ReactNode; + /** Badge(s) shown next to the label, identical to other Reqore components. */ + badge?: TReqoreBadge | TReqoreBadge[]; + /** Icon shown in the leading icon tile. */ + icon?: IReqoreIconName; + /** Image rendered inside the leading icon tile (overrides `icon` if both set). */ + iconImage?: string; + /** Custom color for the leading icon (defaults to intent or readable color). */ + iconColor?: TReqoreEffectColor; + /** Additional props passed to the leading ReqoreIcon. */ + iconProps?: Partial; + /** Right-side action button(s). */ + actions?: IReqoreEntityRowAction[]; + /** Hide the intent-tinted background. Mirrors `transparent` on other components. */ + transparent?: boolean; + /** Round the row corners. Default `true`. */ + rounded?: boolean; + /** Show the leading icon tile. Default `true` when icon/iconImage is provided. */ + showIcon?: boolean; + /** Effect applied to the label text. */ + labelEffect?: IReqoreEffect; + /** Effect applied to the description text. */ + descriptionEffect?: IReqoreEffect; + /** Effect applied to the metadata text. */ + metadataEffect?: IReqoreEffect; + /** + * Whether description and metadata wrap when they overflow. + * - `true` (default): wrap to multiple lines + * - `false`: single line with ellipsis + */ + wrap?: boolean; +} + +interface IStyledRowProps { + theme: IReqoreTheme; + $intent?: TReqoreIntent; + $transparent?: boolean; + size: TSizes; + $fluid?: boolean; + flat?: boolean; + $clickable?: boolean; + rounded?: boolean; + disabled?: boolean; + $hasIcon?: boolean; +} + +const ICON_TILE_SIZE_FROM_SIZE: Record = { + micro: 18, + tiny: 22, + small: 26, + normal: 32, + big: 40, + huge: 48, + massive: 56, +}; + +const tintedBgFor = (theme: IReqoreTheme, intent?: TReqoreIntent) => + intent + ? rgba(theme.intents[intent], 0.06) + : rgba(changeLightness(getMainBackgroundColor(theme), 0.04), 1); + +const StyledRow = styled(StyledEffect)` + display: grid; + grid-template-columns: ${({ $hasIcon, size }) => + $hasIcon ? `${ICON_TILE_SIZE_FROM_SIZE[size]}px 1fr auto` : '1fr auto'}; + gap: ${({ size }) => PADDING_FROM_SIZE[size] * 2}px; + padding: ${({ size }) => PADDING_FROM_SIZE[size] * 2}px + ${({ size }) => PADDING_FROM_SIZE[size] * 3}px; + border-radius: ${({ rounded, size }) => (rounded ? `${RADIUS_FROM_SIZE[size]}px` : '0')}; + background-color: ${({ theme, $intent, $transparent }) => + $transparent ? 'transparent' : tintedBgFor(theme, $intent)}; + border: ${({ flat, theme, $intent }) => + flat + ? 'none' + : `1px solid ${changeLightness( + $intent ? theme.intents[$intent] : getMainBackgroundColor(theme), + 0.08 + )}`}; + align-items: center; + width: ${({ $fluid }) => ($fluid ? '100%' : 'auto')}; + color: ${({ theme }) => getReadableColor(theme, undefined, undefined, true)}; + transition: background-color 0.15s ease-out; + + ${({ $clickable, theme, $intent }) => + $clickable && + css` + cursor: pointer; + &:hover { + background-color: ${$intent + ? rgba(theme.intents[$intent], 0.1) + : rgba(changeLightness(getMainBackgroundColor(theme), 0.08), 1)}; + } + `} + + ${({ disabled }) => disabled && DisabledElement} +`; + +interface IStyledIconTileProps { + $size: TSizes; + $intent?: TReqoreIntent; + theme: IReqoreTheme; +} + +const StyledIconTile = styled.div` + width: ${({ $size }) => ICON_TILE_SIZE_FROM_SIZE[$size]}px; + height: ${({ $size }) => ICON_TILE_SIZE_FROM_SIZE[$size]}px; + border-radius: ${({ $size }) => RADIUS_FROM_SIZE[$size]}px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + background-color: ${({ theme, $intent }) => + $intent + ? rgba(theme.intents[$intent], 0.15) + : rgba(changeLightness(getMainBackgroundColor(theme), 0.06), 1)}; +`; + +const StyledBody = styled.div` + display: flex; + flex-flow: column; + gap: 2px; + min-width: 0; +`; + +const StyledLabelLine = styled.div<{ $wrap: boolean }>` + display: flex; + align-items: center; + gap: 8px; + flex-wrap: ${({ $wrap }) => ($wrap ? 'wrap' : 'nowrap')}; + min-width: 0; + font-weight: 500; +`; + +// `text-overflow: ellipsis` only works on the actual text-bearing element, +// not its wrapper. Cascade into the inner / via `& > *` +// so the `…` glyph appears. The wrapper itself stays a flex item that can +// shrink below its content (`min-width: 0; flex: 1 1 auto`). +const StyledTextSlot = styled.div<{ $wrap: boolean }>` + min-width: 0; + ${({ $wrap }) => + !$wrap && + css` + flex: 1 1 auto; + overflow: hidden; + + & > * { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: block; + max-width: 100%; + } + `} +`; + +const ReqoreEntityRow = memo( + forwardRef( + ( + { + label, + description, + metadata, + badge, + icon, + iconImage, + iconColor, + iconProps, + actions, + intent, + transparent = false, + showIcon, + size = 'normal', + flat = true, + fluid = true, + rounded = true, + customTheme, + inheritCustomTheme, + disabled, + tooltip, + effect, + labelEffect, + descriptionEffect, + metadataEffect, + wrap = true, + className, + ...rest + }, + ref + ) => { + const theme = useReqoreTheme('main', customTheme, undefined, undefined, inheritCustomTheme); + const secondarySize = useMemo(() => getOneLessSize(size), [size]); + + const hasIcon = (showIcon ?? (!!icon || !!iconImage)) && (!!icon || !!iconImage); + const interactive = !!(rest.onClick || rest.onDoubleClick); + const hasBadge = badge !== undefined && badge !== null; + + const resolvedIconColor: TReqoreEffectColor = useMemo(() => { + if (iconColor) return iconColor; + if (intent) return theme.intents[intent] as TReqoreEffectColor; + return getReadableColor(theme, undefined, undefined, true) as TReqoreEffectColor; + }, [iconColor, intent, theme]); + + return ( + + {hasIcon && ( + + + + )} + + + + + {label} + + + {hasBadge && ( + + )} + + {description && ( + + + {description} + + + )} + {metadata && ( + + + {metadata} + + + )} + + {actions && actions.length > 0 && ( + + {actions.map((action, idx) => ( + + {action.label} + + ))} + + )} + + ); + } + ) +); + +export default ReqoreEntityRow; diff --git a/src/components/FeatureCard/index.tsx b/src/components/FeatureCard/index.tsx index c1139e3d..598d35b7 100644 --- a/src/components/FeatureCard/index.tsx +++ b/src/components/FeatureCard/index.tsx @@ -1,9 +1,10 @@ -import { forwardRef, memo, useMemo } from 'react'; import { rgba } from 'polished'; +import { forwardRef, memo, useMemo } from 'react'; import styled, { css } from 'styled-components'; import { HEADER_SIZE_TO_NUMBER, PADDING_FROM_SIZE, + RADIUS_FROM_SIZE, TEXT_FROM_SIZE, TSizes, } from '../../constants/sizes'; @@ -28,6 +29,7 @@ import { IWithReqoreSize, IWithReqoreTooltip, } from '../../types/global'; +import { ButtonBadge, TReqoreBadge } from '../Button'; import { IReqoreEffect, StyledEffect } from '../Effect'; import { ReqoreHeading } from '../Header'; import { ReqoreP } from '../Paragraph'; @@ -46,19 +48,39 @@ export interface IReqoreFeatureCardProps IWithReqoreFluid, IWithReqoreSize, IWithReqoreTooltip { + /** Card heading. */ label: React.ReactNode; + /** Effect applied to the label heading. */ labelEffect?: IReqoreEffect; + /** Body copy under the label. */ description?: React.ReactNode; + /** Effect applied to the description paragraph. */ descriptionEffect?: IReqoreEffect; + /** Visual marker rendered above the label. */ marker?: TReqoreFeatureCardMarker; + /** Used when `marker === 'number'`. */ markerLabel?: string | number; + /** Effect applied to the marker label. */ markerEffect?: IReqoreEffect; + /** Badge(s) shown next to the label, identical to other Reqore components. */ + badge?: TReqoreBadge | TReqoreBadge[]; + /** Round the card corners. Default `true`. */ rounded?: boolean; + /** Marks the card as clickable; auto-detected from `onClick`. */ interactive?: boolean; + /** Hide the card's tinted background. */ + transparent?: boolean; + /** + * Whether the description wraps when it overflows. + * - `true` (default): wrap to multiple lines + * - `false`: single line with ellipsis + */ + wrap?: boolean; } -interface IStyledFeatureCardProps extends IReqoreFeatureCardProps { +interface IStyledFeatureCardProps extends Omit { theme: IReqoreTheme; + $transparent?: boolean; } const StyledFeatureCard = styled(StyledEffect)` @@ -68,7 +90,8 @@ const StyledFeatureCard = styled(StyledEffect)` width: ${({ fluid, fixed }) => (fluid && !fixed ? '100%' : undefined)}; max-width: 100%; padding: ${({ size = 'normal' }) => PADDING_FROM_SIZE[size] * 3}px; - background-color: ${({ theme }) => changeDarkness(getMainBackgroundColor(theme), 0.03)}; + background-color: ${({ theme, $transparent }) => + $transparent ? 'transparent' : changeDarkness(getMainBackgroundColor(theme), 0.03)}; border: ${({ theme, intent, flat }) => flat ? 0 @@ -76,15 +99,13 @@ const StyledFeatureCard = styled(StyledEffect)` intent ? theme.intents[intent] : getMainBackgroundColor(theme), 0.08 )}`}; - border-radius: ${({ rounded }) => (rounded ? '8px' : 0)}; + border-radius: ${({ rounded, size = 'normal' }) => + rounded === false ? 0 : `${RADIUS_FROM_SIZE[size]}px`}; color: ${({ theme }) => getReadableColor(theme, undefined, undefined, true)}; overflow: hidden; position: relative; flex: ${({ fluid }) => (fluid ? '1 auto' : '0 0 auto')}; - transition: - border-color 0.16s ease, - background-color 0.16s ease, - transform 0.16s ease; + transition: border-color 0.16s ease, background-color 0.16s ease, transform 0.16s ease; ${({ disabled }) => disabled && DisabledElement} @@ -130,8 +151,10 @@ const StyledFeatureCardMarker = styled.div<{ : changeLightness(getMainBackgroundColor(theme), 0.22)}; box-shadow: 0 0 22px ${rgba( - intent ? theme.intents[intent] : changeLightness(getMainBackgroundColor(theme), 0.22), - 0.3 + intent + ? theme.intents[intent] + : changeLightness(getMainBackgroundColor(theme), 0.22), + 0.8 )}; } ` @@ -144,6 +167,34 @@ const StyledFeatureCardContent = styled.div` gap: 12px; `; +const StyledLabelRow = styled.div` + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + min-width: 0; +`; + +// Cascade ellipsis CSS into the inner ReqoreP — `text-overflow: ellipsis` +// only takes effect on the actual text-bearing element. +const StyledTextSlot = styled.div<{ $wrap: boolean }>` + min-width: 0; + ${({ $wrap }) => + !$wrap && + css` + flex: 1 1 auto; + overflow: hidden; + + & > * { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: block; + max-width: 100%; + } + `} +`; + export const ReqoreFeatureCard = memo( forwardRef( ( @@ -155,6 +206,7 @@ export const ReqoreFeatureCard = memo( marker = 'line', markerLabel, markerEffect, + badge, size = 'normal', customTheme, inheritCustomTheme, @@ -165,7 +217,9 @@ export const ReqoreFeatureCard = memo( fixed, disabled, tooltip, - rounded, + rounded = true, + transparent = false, + wrap = true, effect, interactive, onClick, @@ -174,12 +228,10 @@ export const ReqoreFeatureCard = memo( ref ) => { const theme = useReqoreTheme('main', customTheme, undefined, undefined, inheritCustomTheme); - const labelSize = useMemo( - () => HEADER_SIZE_TO_NUMBER[size] as 1 | 2 | 3 | 4 | 5 | 6, - [size] - ); + const labelSize = useMemo(() => HEADER_SIZE_TO_NUMBER[size] as 1 | 2 | 3 | 4 | 5 | 6, [size]); const descriptionSize = useMemo(() => getOneLessSize(size), [size]); const isInteractive = interactive || !!onClick; + const hasBadge = badge !== undefined && badge !== null; return ( )} - - {label} - + + + + {label} + + + {hasBadge && } + {description && ( - - {description} - + + + {description} + + )} diff --git a/src/components/Icon/index.tsx b/src/components/Icon/index.tsx index fbafc939..666b2228 100644 --- a/src/components/Icon/index.tsx +++ b/src/components/Icon/index.tsx @@ -1,3 +1,4 @@ +import { rgba } from 'polished'; import React, { forwardRef, memo, useMemo } from 'react'; import { IconContext } from 'react-icons'; import { IconBaseProps, IconType } from 'react-icons/lib'; @@ -5,8 +6,9 @@ import * as RemixIcons from 'react-icons/ri'; import styled, { css, keyframes } from 'styled-components'; import { useReqoreTheme } from '../..'; import { ICON_FROM_SIZE, PADDING_FROM_SIZE, TSizes } from '../../constants/sizes'; -import { getColorFromMaybeString } from '../../helpers/colors'; +import { getColorFromMaybeString, getReadableColor } from '../../helpers/colors'; import { isStringSize } from '../../helpers/utils'; +import { useReqoreProperty } from '../../hooks/useReqoreContext'; import { IReqoreIntent, IWithReqoreEffect, IWithReqoreTooltip } from '../../types/global'; import { IReqoreIconName } from '../../types/icons'; import { StyledEffect, TReqoreEffectColor } from '../Effect'; @@ -33,6 +35,21 @@ export interface IReqoreIconProps animation?: 'spin' | 'heartbeat'; interactive?: boolean; compact?: boolean; + /** + * Subtle glow rendered behind the icon shape via `filter: drop-shadow(...)` + * so it follows the icon's actual contour rather than its bounding box. + * + * - `true` — glow with the icon's resolved colour (intent / `color` prop / readable default) + * - `TReqoreEffectColor` — glow with that specific colour + * - object — `{ color?, blur?, opacity? }` for full control + */ + glow?: boolean | TReqoreEffectColor | IReqoreGlowConfig; +} + +export interface IReqoreGlowConfig { + color?: TReqoreEffectColor; + blur?: number; + opacity?: number; } const SpinKeyframes = keyframes` @@ -109,11 +126,13 @@ const ReqoreIcon = memo( iconProps, intent, image, + glow, ...rest }: IReqoreIconProps, ref ) => { const theme = useReqoreTheme(); + const glowingIconsDefault = useReqoreProperty('glowingIcons'); const Icon: IconType = RemixIcons[`Ri${icon}`]; const finalColor: string | undefined = useMemo( () => (intent ? theme.intents[intent] : getColorFromMaybeString(theme, color)), @@ -128,9 +147,45 @@ const ReqoreIcon = memo( const finalMarginSize: number = isStringSize(marginSize) ? PADDING_FROM_SIZE[marginSize] : marginSize; + + // Resolve the glow shorthand into a `filter: drop-shadow(...)` value so + // the glow follows the SVG outline rather than the wrapper's bounding box. + // When the local `glow` prop is undefined, fall back to the global + // `glowingIcons` UI option so a single switch can turn glows on app-wide. + // Pass `glow={false}` explicitly to opt a single icon out. + const effectiveGlow = glow === undefined ? glowingIconsDefault : glow; + const glowFilter = useMemo(() => { + if (!effectiveGlow) return undefined; + const config: IReqoreGlowConfig = + typeof effectiveGlow === 'boolean' + ? {} + : typeof effectiveGlow === 'string' + ? { color: effectiveGlow } + : effectiveGlow; + // Resolution order: explicit glow.color → icon's intent/color prop. + // When the prop was set explicitly (not via the global `glowingIcons` + // option), fall back to a theme-readable colour so a plain icon still + // glows. The global flag intentionally does NOT force-glow uncoloured + // icons — it only enhances icons that already have an intent/colour. + const explicit = glow !== undefined; + const resolved = + (config.color ? (getColorFromMaybeString(theme, config.color) as string) : undefined) ?? + finalColor ?? + (explicit ? getReadableColor(theme, undefined, undefined, true) : undefined); + if (!resolved) return undefined; + const blur = config.blur ?? 5; + const opacity = config.opacity ?? 0.8; + return `drop-shadow(0 0 ${blur}px ${rgba(resolved, opacity)})`; + }, [effectiveGlow, glow, finalColor, theme]); + const finalStyle = useMemo( - () => ({ width: finalWrapperSize, height: finalWrapperSize, ...style }), - [finalWrapperSize, style] + () => ({ + width: finalWrapperSize, + height: finalWrapperSize, + ...(glowFilter ? { filter: glowFilter } : {}), + ...style, + }), + [finalWrapperSize, glowFilter, style] ); if (image) { diff --git a/src/components/Progress/index.tsx b/src/components/Progress/index.tsx index 4dec54c9..ff549046 100644 --- a/src/components/Progress/index.tsx +++ b/src/components/Progress/index.tsx @@ -63,6 +63,17 @@ export interface IReqoreProgressProps rightIconColor?: TReqoreEffectColor; /** Right icon props */ rightIconProps?: IReqoreIconProps; + /** + * Target marker drawn on top of the track at this value (must be between 0 and `max`). + * Useful for visualising goals (e.g. "90% target coverage"). Hidden when not set. + */ + target?: number; + /** Optional label rendered above the target marker. Defaults to the target percentage. */ + targetLabel?: string; + /** Whether to render the marker label above the marker line. Defaults to `true`. */ + showTargetLabel?: boolean; + /** Custom color for the target marker line/label. Falls back to a theme-readable colour. */ + targetColor?: TReqoreEffectColor; } export interface IReqoreProgressStyle extends IReqoreProgressProps { @@ -156,6 +167,38 @@ const StyledProgressTrack = styled.div` flat === false ? `1px solid ${rgba(getProgressColor(theme), 0.6)}` : 'none'}; `; +interface IStyledProgressTargetProps { + $left: number; + $color: string; +} + +const StyledProgressTargetMarker = styled.div` + position: absolute; + top: -2px; + bottom: -2px; + left: ${({ $left }) => $left}%; + width: 2px; + background-color: ${({ $color }) => $color}; + border-radius: 1px; + pointer-events: none; + z-index: 2; +`; + +const StyledProgressTargetLabel = styled.div<{ $left: number; $color: string }>` + position: absolute; + bottom: calc(100% + 4px); + left: ${({ $left }) => $left}%; + transform: translateX(-50%); + font-size: 9px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: ${({ $color }) => $color}; + white-space: nowrap; + pointer-events: none; + z-index: 2; +`; + const StyledProgressBar = styled.div` position: absolute; top: 0; @@ -218,6 +261,10 @@ const ReqoreProgress = memo( rightIcon, rightIconColor, rightIconProps, + target, + targetLabel, + showTargetLabel = true, + targetColor, ...rest }, ref @@ -238,6 +285,23 @@ const ReqoreProgress = memo( return `${Math.round(percentage)}%`; }, [showValue, percentage, indeterminate]); + const targetPercent = useMemo(() => { + if (target === undefined || indeterminate || max <= 0) return null; + const clamped = Math.max(0, Math.min(target, max)); + return (clamped / max) * 100; + }, [target, max, indeterminate]); + + const resolvedTargetColor = useMemo(() => { + if (targetColor) return targetColor as string; + return getReadableColor(baseTheme, undefined, undefined, true); + }, [targetColor, baseTheme]); + + const resolvedTargetLabel = useMemo(() => { + if (targetPercent === null) return null; + if (targetLabel !== undefined) return targetLabel; + return `Target ${Math.round(targetPercent)}%`; + }, [targetPercent, targetLabel]); + const hasLabels = !!( label || icon || @@ -304,6 +368,25 @@ const ReqoreProgress = memo( animated={animated} className='reqore-progress-bar' /> + {targetPercent !== null && ( + <> + {showTargetLabel && resolvedTargetLabel && ( + + {resolvedTargetLabel} + + )} + + + )} ); diff --git a/src/components/SeverityRow/index.tsx b/src/components/SeverityRow/index.tsx new file mode 100644 index 00000000..cc8a0df6 --- /dev/null +++ b/src/components/SeverityRow/index.tsx @@ -0,0 +1,284 @@ +import { rgba } from 'polished'; +import { forwardRef, memo, useMemo } from 'react'; +import styled, { css } from 'styled-components'; +import { PADDING_FROM_SIZE, RADIUS_FROM_SIZE, TSizes } from '../../constants/sizes'; +import { IReqoreTheme, TReqoreIntent } from '../../constants/theme'; +import { changeLightness, getMainBackgroundColor, getReadableColor } from '../../helpers/colors'; +import { getOneLessSize } from '../../helpers/utils'; +import { useReqoreTheme } from '../../hooks/useTheme'; +import { DisabledElement } from '../../styles'; +import { + IReqoreDisabled, + IReqoreIntent, + IWithReqoreCustomTheme, + IWithReqoreEffect, + IWithReqoreFlat, + IWithReqoreFluid, + IWithReqoreSize, + IWithReqoreTooltip, +} from '../../types/global'; +import ReqoreButton, { ButtonBadge, IReqoreButtonProps, TReqoreBadge } from '../Button'; +import ReqoreControlGroup from '../ControlGroup'; +import { IReqoreEffect, StyledEffect } from '../Effect'; +import { ReqoreP } from '../Paragraph'; +import { ReqoreSpan } from '../Span'; +import { ReqoreTooltipComponent } from '../TooltipComponent'; + +export interface IReqoreSeverityRowAction extends Omit { + /** Visible button label. */ + label?: string; +} + +export interface IReqoreSeverityRowProps + extends Omit, 'title'>, + IReqoreDisabled, + IReqoreIntent, + IWithReqoreCustomTheme, + IWithReqoreEffect, + IWithReqoreFlat, + IWithReqoreFluid, + IWithReqoreSize, + IWithReqoreTooltip { + /** Primary line — e.g. "Payment Processing · stripe-webhook-receiver". */ + label: React.ReactNode; + /** Secondary line — e.g. "Avg duration 4.7s exceeded 3.5s threshold · just now". */ + description?: React.ReactNode; + /** Optional inline content rendered before the label (e.g. severity Tag). */ + leading?: React.ReactNode; + /** Badge(s) shown next to the label, identical to other Reqore components. */ + badge?: TReqoreBadge | TReqoreBadge[]; + /** Right-side action buttons (Investigate / Dismiss). */ + actions?: IReqoreSeverityRowAction[]; + /** Hide the intent-tinted background. Mirrors `transparent` on other components. */ + transparent?: boolean; + /** Show the colored severity strip on the left edge. Default `true`. */ + showStrip?: boolean; + /** Rounded corners on the row. Default `true`. */ + rounded?: boolean; + /** Effect applied to the label text. */ + labelEffect?: IReqoreEffect; + /** Effect applied to the description text. */ + descriptionEffect?: IReqoreEffect; + /** + * Whether the description wraps when it overflows. + * - `true` (default): wrap to multiple lines + * - `false`: single line with ellipsis + */ + wrap?: boolean; +} + +interface IStyledRowProps { + theme: IReqoreTheme; + $intent?: TReqoreIntent; + $transparent?: boolean; + size: TSizes; + $fluid?: boolean; + flat?: boolean; + $clickable?: boolean; + rounded?: boolean; + disabled?: boolean; +} + +const stripColorFor = (theme: IReqoreTheme, intent?: TReqoreIntent) => + intent ? theme.intents[intent] : changeLightness(getMainBackgroundColor(theme), 0.16); + +const tintedBgFor = (theme: IReqoreTheme, intent?: TReqoreIntent) => + intent + ? rgba(theme.intents[intent], 0.06) + : rgba(changeLightness(getMainBackgroundColor(theme), 0.04), 1); + +const StyledRow = styled(StyledEffect)` + display: grid; + grid-template-columns: 4px 1fr auto; + gap: ${({ size }) => PADDING_FROM_SIZE[size] * 2}px; + padding: ${({ size }) => PADDING_FROM_SIZE[size] * 2}px + ${({ size }) => PADDING_FROM_SIZE[size] * 3}px; + border-radius: ${({ rounded, size }) => (rounded ? `${RADIUS_FROM_SIZE[size]}px` : '0')}; + background-color: ${({ theme, $intent, $transparent }) => + $transparent ? 'transparent' : tintedBgFor(theme, $intent)}; + border: ${({ flat, theme, $intent }) => + flat + ? 'none' + : `1px solid ${changeLightness( + $intent ? theme.intents[$intent] : getMainBackgroundColor(theme), + 0.08 + )}`}; + align-items: center; + width: ${({ $fluid }) => ($fluid ? '100%' : 'auto')}; + color: ${({ theme }) => getReadableColor(theme, undefined, undefined, true)}; + transition: background-color 0.15s ease-out; + + ${({ $clickable, theme, $intent }) => + $clickable && + css` + cursor: pointer; + &:hover { + background-color: ${$intent + ? rgba(theme.intents[$intent], 0.1) + : rgba(changeLightness(getMainBackgroundColor(theme), 0.08), 1)}; + } + `} + + ${({ disabled }) => disabled && DisabledElement} +`; + +const StyledStrip = styled.div<{ $intent?: TReqoreIntent; theme: IReqoreTheme }>` + align-self: stretch; + border-radius: 2px; + background-color: ${({ theme, $intent }) => stripColorFor(theme, $intent)}; + // Subtle glow matching the strip colour — same pattern as FeatureCard's + // line marker. Pulls focus to the row without using a heavier border. + box-shadow: 0 0 22px ${({ theme, $intent }) => rgba(stripColorFor(theme, $intent), 0.3)}; +`; + +const StyledBody = styled.div` + display: flex; + flex-flow: column; + gap: 4px; + min-width: 0; +`; + +const StyledLabelLine = styled.div<{ $wrap: boolean }>` + display: flex; + align-items: center; + gap: 8px; + flex-wrap: ${({ $wrap }) => ($wrap ? 'wrap' : 'nowrap')}; + min-width: 0; + font-weight: 500; +`; + +// `text-overflow: ellipsis` only works on the actual text-bearing element, +// not its wrapper. Cascade the ellipsis CSS into the child / +// so the `…` glyph actually appears, while keeping the wrapper itself a flex +// item that can shrink (`min-width: 0; flex: 1 1 auto`) inside its parent. +const StyledTextSlot = styled.div<{ $wrap: boolean }>` + min-width: 0; + ${({ $wrap }) => + !$wrap && + css` + flex: 1 1 auto; + overflow: hidden; + + & > * { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: block; + max-width: 100%; + } + `} +`; + +const ReqoreSeverityRow = memo( + forwardRef( + ( + { + label, + description, + leading, + badge, + actions, + intent, + transparent = false, + showStrip = true, + size = 'normal', + flat = true, + fluid = true, + rounded = true, + customTheme, + inheritCustomTheme, + disabled, + tooltip, + effect, + labelEffect, + descriptionEffect, + wrap = true, + className, + ...rest + }, + ref + ) => { + const theme = useReqoreTheme('main', customTheme, undefined, undefined, inheritCustomTheme); + const secondarySize = useMemo(() => getOneLessSize(size), [size]); + + const interactive = !!(rest.onClick || rest.onDoubleClick); + + const hasBadge = badge !== undefined && badge !== null; + + return ( + + {showStrip ? ( + + ) : ( + + )} + + + {leading} + + + {label} + + + {hasBadge && ( + + )} + + {description && ( + + + {description} + + + )} + + {actions && actions.length > 0 && ( + + {actions.map((action, idx) => ( + + {action.label} + + ))} + + )} + + ); + } + ) +); + +export default ReqoreSeverityRow; diff --git a/src/containers/ReqoreProvider.tsx b/src/containers/ReqoreProvider.tsx index b7085431..be910f8b 100644 --- a/src/containers/ReqoreProvider.tsx +++ b/src/containers/ReqoreProvider.tsx @@ -262,6 +262,7 @@ const ReqoreProvider: React.FC = memo(({ children, options options && 'closeModalsOnEscPress' in options ? options.closeModalsOnEscPress : true, customPortalId: options?.customPortalId, uiScale: options?.uiScale, + glowingIcons: options?.glowingIcons ?? false, errorBoundaryOptions: options?.errorBoundaryOptions || DEFAULT_ERROR_BOUNDARY_OPTIONS, }), [options] @@ -284,6 +285,7 @@ const ReqoreProvider: React.FC = memo(({ children, options getAndIncreaseZIndex, animations: resolvedOptions.animations, tooltips: resolvedOptions.tooltips, + glowingIcons: resolvedOptions.glowingIcons, closePopoversOnEscPress: resolvedOptions.closePopoversOnEscPress, // ESC Closable modals management closeModalsOnEscPress: resolvedOptions.closeModalsOnEscPress, diff --git a/src/containers/UIProvider.tsx b/src/containers/UIProvider.tsx index de1816b7..5ac0c54d 100644 --- a/src/containers/UIProvider.tsx +++ b/src/containers/UIProvider.tsx @@ -20,6 +20,7 @@ export interface IReqoreOptions | 'tooltips' | 'customPortalId' | 'errorBoundaryOptions' + | 'glowingIcons' > { withSidebar?: boolean; uiScale?: number; diff --git a/src/context/ReqoreContext.tsx b/src/context/ReqoreContext.tsx index f64c486e..9cb06de1 100644 --- a/src/context/ReqoreContext.tsx +++ b/src/context/ReqoreContext.tsx @@ -38,6 +38,13 @@ export interface IReqoreContext { * */ delay?: number; }; + /** + * When `true`, every `` rendered without an explicit `glow` prop + * receives a subtle drop-shadow glow using its resolved colour. Individual icons + * can opt out with `glow={false}`. + * @default false + */ + readonly glowingIcons?: boolean; readonly customPortalId?: string; readonly closePopoversOnEscPress?: boolean; readonly closeModalsOnEscPress?: boolean; diff --git a/src/index.tsx b/src/index.tsx index 45383c0c..3b996dd5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -15,6 +15,7 @@ export * from './components/DatePicker'; export { ReqoreDrawer } from './components/Drawer'; export { ReqoreBackdrop } from './components/Drawer/backdrop'; export { default as ReqoreDropdown } from './components/Dropdown'; +export { default as ReqoreEntityRow } from './components/EntityRow'; export { ReqoreDropdownDivider, ReqoreDropdownItem } from './components/Dropdown/item'; export { ReqoreEffect, ReqoreTextEffect } from './components/Effect'; export { ReqoreEmptyState } from './components/EmptyState'; @@ -56,6 +57,7 @@ export { default as ReqoreProgress } from './components/Progress'; export { default as ReqoreRadioGroup } from './components/RadioGroup'; export { default as ReqoreRating } from './components/Rating'; export { default as ReqoreSegmentedControl } from './components/SegmentedControl'; +export { default as ReqoreSeverityRow } from './components/SeverityRow'; export { ReqoreRichTextEditor } from './components/RichTextEditor'; export { ReqoreSkeleton } from './components/Skeleton'; export { ReqoreSlider } from './components/Slider'; diff --git a/src/stories/Callout/Callout.stories.tsx b/src/stories/Callout/Callout.stories.tsx index 35e3ca99..e8ace1ff 100644 --- a/src/stories/Callout/Callout.stories.tsx +++ b/src/stories/Callout/Callout.stories.tsx @@ -146,3 +146,150 @@ export const ContentEffect: Story = { }, }, }; + +export const WithLabel: Story = { + render: Template, + args: { + label: 'Configuration required', + description: + 'Set up your AI provider before publishing this Qog. The build will succeed but runs will fail at execution time.', + intent: 'warning', + }, +}; + +export const WithIcon: Story = { + render: Template, + args: { + label: 'Heads up', + description: 'Icons inherit the intent colour automatically.', + icon: 'InformationLine', + intent: 'info', + }, +}; + +export const WithBadge: Story = { + render: Template, + args: { + label: 'New release available', + description: 'A new version of the platform is ready to install.', + icon: 'AlertLine', + intent: 'info', + badge: { label: 'v7.2', intent: 'info' }, + }, +}; + +export const WithMultipleBadges: Story = { + render: Template, + args: { + label: 'Pending approvals', + description: 'Three workflows are waiting for review across two business areas.', + icon: 'TimeLine', + intent: 'pending', + badge: [3, { label: 'high priority', intent: 'danger' }], + }, +}; + +export const Closable: Story = { + render: Template, + args: { + label: 'Cookie notice', + description: 'We use cookies to improve your experience. Dismiss to acknowledge.', + icon: 'InformationLine', + intent: 'info', + onClose: () => alert('Closed'), + }, +}; + +export const Bordered: Story = { + render: Template, + args: { + label: 'Bordered callout', + description: 'flat={false} renders an intent-coloured border in addition to the accent.', + icon: 'InformationLine', + intent: 'info', + flat: false, + }, +}; + +export const Square: Story = { + render: Template, + args: { + label: 'Square corners', + description: 'rounded={false} removes the corner radius.', + rounded: false, + }, +}; + +export const Transparent: Story = { + render: Template, + args: { + label: 'Transparent surface', + description: 'transparent={true} drops the surface colour, leaving the accent strip visible.', + icon: 'InformationLine', + intent: 'info', + transparent: true, + }, +}; + +export const Clickable: Story = { + render: Template, + args: { + label: 'Whole-callout click', + description: 'Provide onClick (or interactive) to enable hover/lift behaviour.', + icon: 'CursorLine', + intent: 'info', + onClick: () => alert('Callout clicked'), + }, +}; + +export const Disabled: Story = { + render: Template, + args: { + label: 'Disabled callout', + description: 'Disabled callouts dim and do not respond to hover or click.', + icon: 'CloseCircleLine', + disabled: true, + }, +}; + +export const Tooltip: Story = { + render: Template, + args: { + children: 'Hover me to see a tooltip.', + tooltip: 'Callouts expose the same tooltip prop as every other Reqore component.', + }, +}; + +export const Fixed: Story = { + render: (args) => , + args: { + label: 'Fixed width callout', + description: 'fixed={true} prevents the callout from stretching.', + icon: 'InformationLine', + intent: 'info', + }, +}; + +export const CustomTheme: Story = { + render: Template, + args: { + label: 'Branded callout', + description: 'customTheme overrides the surface colour while keeping the accent strip.', + icon: 'InformationLine', + customTheme: { main: '#1a1142' }, + }, +}; + +export const FullyComposed: Story = { + render: Template, + args: { + label: 'New deploy ready · staging', + description: + 'Promotion to production is gated on the staging smoke tests; pass them then click Promote.', + icon: 'RocketLine', + intent: 'success', + badge: { label: 'v7.2', intent: 'success' }, + onClose: () => alert('Dismissed'), + accentSize: 4, + }, +}; diff --git a/src/stories/EmptyState/EmptyState.stories.tsx b/src/stories/EmptyState/EmptyState.stories.tsx index f957c6ee..05b2b036 100644 --- a/src/stories/EmptyState/EmptyState.stories.tsx +++ b/src/stories/EmptyState/EmptyState.stories.tsx @@ -4,7 +4,7 @@ import { ReqoreButton, ReqoreControlGroup } from '../../index'; import { StoryMeta } from '../utils'; const meta = { - title: 'Data Display/EmptyState/Stories', + title: 'Display/Empty State/Stories', component: ReqoreEmptyState, } as StoryMeta; diff --git a/src/stories/EntityRow/EntityRow.stories.tsx b/src/stories/EntityRow/EntityRow.stories.tsx new file mode 100644 index 00000000..3902f92d --- /dev/null +++ b/src/stories/EntityRow/EntityRow.stories.tsx @@ -0,0 +1,214 @@ +import { StoryObj } from '@storybook/react'; +import ReqoreControlGroup from '../../components/ControlGroup'; +import ReqoreEntityRow from '../../components/EntityRow'; +import { StoryMeta } from '../utils'; + +const meta = { + title: 'Display/Entity Row/Stories', + component: ReqoreEntityRow, +} as StoryMeta; + +export default meta; +type Story = StoryObj; + +export const Basic: Story = { + args: { + label: 'Process Incoming Order', + description: 'Routes incoming Shopify orders into the warehouse pipeline', + metadata: 'Last run: success · just now · 384ms', + icon: 'PlayCircleLine', + actions: [{ label: 'Run', icon: 'PlayLine' }], + }, +}; + +export const WithBadge: Story = { + args: { + label: 'Reconcile Payments', + description: 'Daily reconciliation between Stripe and the ledger', + metadata: 'Last run: failed · 1.5s', + icon: 'ErrorWarningLine', + intent: 'danger', + badge: { label: 'Failed', intent: 'danger' }, + actions: [{ label: 'Investigate', intent: 'danger' }], + }, +}; + +export const WithMultipleBadges: Story = { + args: { + label: 'Process Incoming Order', + description: 'Routes incoming Shopify orders into the warehouse pipeline', + metadata: 'Last run: success · just now', + icon: 'PlayCircleLine', + intent: 'success', + badge: [ + { label: 'v2', minimal: true }, + { label: 'on-demand', intent: 'info', minimal: true }, + ], + actions: [{ label: 'Run', icon: 'PlayLine' }], + }, +}; + +export const WithIntents: Story = { + render: (args) => ( + + + + + + ), +}; + +export const WithImage: Story = { + args: { + label: 'Stripe payment integration', + description: 'OAuth2 connected · Last sync 5 minutes ago', + iconImage: 'https://stripe.com/img/v3/home/social.png', + actions: [{ label: 'Manage' }], + }, +}; + +export const WithoutIcon: Story = { + args: { + label: 'Plain row, no icon tile', + description: 'Useful for very compact lists where the entity speaks for itself', + metadata: 'just now', + actions: [{ icon: 'ArrowRightSLine', minimal: true, flat: true }], + }, +}; + +export const Sizes: Story = { + render: () => ( + + {(['tiny', 'small', 'normal', 'big'] as const).map((size) => ( + + ))} + + ), +}; + +export const Bordered: Story = { + args: { + label: 'Bordered entity row', + description: 'flat={false} renders an intent-coloured border', + metadata: 'just now', + icon: 'SettingsLine', + intent: 'info', + flat: false, + }, +}; + +export const Square: Story = { + args: { + label: 'Square entity row', + description: 'rounded={false} removes the corner radius', + metadata: 'just now', + icon: 'SettingsLine', + intent: 'success', + rounded: false, + }, +}; + +export const WithEffects: Story = { + args: { + label: 'Effects on label, description, metadata', + description: 'Description with custom italic effect', + metadata: 'Metadata with custom uppercase effect', + icon: 'SparklingLine', + intent: 'info', + labelEffect: { weight: 'bold', uppercase: true, spaced: 1 }, + descriptionEffect: { italic: true, opacity: 0.8 }, + metadataEffect: { uppercase: true, spaced: 2, opacity: 0.6 }, + effect: { + gradient: { + colors: { 0: 'info:darken:5', 100: 'transparent' }, + direction: 'to right', + }, + }, + }, +}; + +export const Clickable: Story = { + args: { + label: 'Inventory Reorder Trigger', + description: 'Watches stock thresholds and fires reorder workflows', + metadata: 'Last run: success · 16 hours ago', + icon: 'StackLine', + onClick: () => alert('Row clicked'), + actions: [{ icon: 'ArrowRightSLine', minimal: true, flat: true }], + }, +}; + +export const Disabled: Story = { + args: { + label: 'Archived integration', + description: 'This automation has been archived', + icon: 'ArchiveLine', + disabled: true, + actions: [{ label: 'Restore' }], + }, +}; + +export const NoWrap: Story = { + args: { + label: 'Process Incoming Order', + description: + 'Routes incoming Shopify orders into the warehouse pipeline with full validation against the SKU registry', + metadata: 'Last run: success · just now · 384ms · attempt 1 of 3', + icon: 'PlayCircleLine', + intent: 'success', + wrap: false, + actions: [{ label: 'Run', icon: 'PlayLine' }], + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const Transparent: Story = { + args: { + label: 'Transparent entity row', + description: 'Even with intent set, the background stays transparent', + icon: 'SettingsLine', + intent: 'info', + transparent: true, + actions: [{ label: 'Open' }], + }, +}; diff --git a/src/stories/FeatureCard/FeatureCard.stories.tsx b/src/stories/FeatureCard/FeatureCard.stories.tsx index b7c3ed85..07a685e5 100644 --- a/src/stories/FeatureCard/FeatureCard.stories.tsx +++ b/src/stories/FeatureCard/FeatureCard.stories.tsx @@ -1,6 +1,6 @@ import { StoryFn, StoryObj } from '@storybook/react'; -import { ReqoreFeatureCard, IReqoreFeatureCardProps } from '../../components/FeatureCard'; import ReqoreControlGroup from '../../components/ControlGroup'; +import { IReqoreFeatureCardProps, ReqoreFeatureCard } from '../../components/FeatureCard'; import { DEFAULT_INTENTS } from '../../constants/theme'; import { StoryMeta } from '../utils'; import { FlatArg, IntentArg, SizeArg, argManager } from '../utils/args'; @@ -8,7 +8,7 @@ import { FlatArg, IntentArg, SizeArg, argManager } from '../utils/args'; const { createArg } = argManager(); const meta = { - title: 'Display/FeatureCard/Stories', + title: 'Display/Feature Card/Stories', component: ReqoreFeatureCard, parameters: { chromatic: { @@ -136,3 +136,132 @@ export const FrostedLabel: Story = { }, }, }; + +export const Bordered: Story = { + render: Template, + args: { + label: 'Bordered card', + description: 'flat={false} renders an intent-coloured border around the card.', + intent: 'info', + flat: false, + }, +}; + +export const Square: Story = { + render: Template, + args: { + label: 'Square corners', + description: 'rounded={false} removes the corner radius.', + rounded: false, + }, +}; + +export const Transparent: Story = { + render: Template, + args: { + label: 'Transparent surface', + description: 'transparent={true} drops the tinted background, leaving the border + marker.', + intent: 'warning', + transparent: true, + }, +}; + +export const Clickable: Story = { + render: Template, + args: { + label: 'Clickable card', + description: 'Provide onClick (or interactive) to enable hover/lift behaviour.', + onClick: () => alert('Card clicked'), + }, +}; + +export const Disabled: Story = { + render: Template, + args: { + label: 'Disabled card', + description: 'Disabled cards dim and do not respond to hover or click.', + disabled: true, + onClick: () => { + // no-op while disabled + }, + }, +}; + +export const Tooltip: Story = { + render: Template, + args: { + label: 'With tooltip', + description: 'Hover the card to see a contextual tooltip.', + tooltip: 'Cards expose the same tooltip prop as every other Reqore component.', + }, +}; + +export const Fixed: Story = { + render: (args) => , + args: { + label: 'Fixed width card', + description: 'fixed={true} prevents the card from stretching.', + }, +}; + +export const WithBadge: Story = { + render: Template, + args: { + label: 'Just shipped', + description: 'Badges render to the right of the label using the standard TReqoreBadge type.', + badge: { label: 'New', intent: 'success' }, + intent: 'success', + }, +}; + +export const WithMultipleBadges: Story = { + render: Template, + args: { + label: 'Pricing tier', + description: 'Pass an array of badges to surface several pieces of metadata at once.', + badge: [ + { label: 'v2', minimal: true }, + { label: 'beta', intent: 'warning', minimal: true }, + ], + }, +}; + +export const NoWrap: Story = { + render: (args) => ( +
+ +
+ ), + args: { + label: 'Process incoming order with strict SKU validation', + description: + 'Routes Shopify orders into the warehouse pipeline and validates each line against the master catalog before fulfilment.', + wrap: false, + }, +}; + +export const WithEffects: Story = { + render: Template, + args: { + label: 'Effects everywhere', + description: 'Background gradient, label uppercase, italic description.', + intent: 'info', + effect: { + gradient: { + colors: { 0: 'info:darken:5', 100: 'transparent' }, + direction: 'to right', + }, + }, + labelEffect: { weight: 'bold', uppercase: true, spaced: 1 }, + descriptionEffect: { italic: true }, + }, +}; + +export const CustomTheme: Story = { + render: Template, + args: { + label: 'Branded card', + description: 'customTheme overrides the surface colour while keeping the same primitives.', + customTheme: { main: '#1a1142' }, + }, +}; diff --git a/src/stories/Icon/Icon.stories.tsx b/src/stories/Icon/Icon.stories.tsx index f65584fc..1ce7e43a 100644 --- a/src/stories/Icon/Icon.stories.tsx +++ b/src/stories/Icon/Icon.stories.tsx @@ -1,5 +1,5 @@ import { StoryObj } from '@storybook/react'; -import { ReqoreIcon, ReqorePanel } from '../../index'; +import { ReqoreControlGroup, ReqoreIcon, ReqorePanel, ReqoreUIProvider } from '../../index'; import { StoryMeta } from '../utils'; const meta = { @@ -170,3 +170,48 @@ export const Basic: Story = { ); }, }; + +export const Glow: Story = { + render: () => ( + + + + + + + + + + + ), +}; + +export const GlobalGlowingIcons: Story = { + render: () => ( + + + + + + + + {/* Opt-out: pass glow={false} explicitly */} + + + + + ), + parameters: { + docs: { + description: { + story: + 'Setting `glowingIcons: true` on the UI provider applies the glow to every ReqoreIcon by default. Individual icons can opt out with `glow={false}`.', + }, + }, + }, +}; diff --git a/src/stories/Progress/Progress.stories.tsx b/src/stories/Progress/Progress.stories.tsx index d1a63604..f07b4444 100644 --- a/src/stories/Progress/Progress.stories.tsx +++ b/src/stories/Progress/Progress.stories.tsx @@ -291,6 +291,54 @@ export const Disabled: Story = { ), }; +export const WithTargetMarker: Story = { + render: (args) => ( + + + + + + + + ), +}; + export const NotRounded: Story = { render: (args) => ( diff --git a/src/stories/SeverityRow/SeverityRow.stories.tsx b/src/stories/SeverityRow/SeverityRow.stories.tsx new file mode 100644 index 00000000..62fef70a --- /dev/null +++ b/src/stories/SeverityRow/SeverityRow.stories.tsx @@ -0,0 +1,201 @@ +import { StoryObj } from '@storybook/react'; +import ReqoreControlGroup from '../../components/ControlGroup'; +import ReqoreSeverityRow from '../../components/SeverityRow'; +import ReqoreTag from '../../components/Tag'; +import { StoryMeta } from '../utils'; + +const meta = { + title: 'Display/Severity Row/Stories', + component: ReqoreSeverityRow, +} as StoryMeta; + +export default meta; +type Story = StoryObj; + +export const Basic: Story = { + args: { + label: 'Payment Processing · stripe-webhook-receiver', + description: 'Avg duration 4.7s exceeded 3.5s threshold · just now', + intent: 'danger', + leading: , + actions: [ + { label: 'Investigate', intent: 'danger' }, + { icon: 'CloseLine', tooltip: 'Dismiss', minimal: true, flat: true }, + ], + }, +}; + +export const WithBadge: Story = { + args: { + label: 'Payment Processing', + description: 'Three open detector flags from the last summary cycle', + intent: 'danger', + badge: [3, { label: 'mad', intent: 'danger', minimal: true }], + actions: [{ label: 'Investigate', intent: 'danger' }], + }, +}; + +export const Intents: Story = { + render: (args) => ( + + } + actions={[{ label: 'Investigate', intent: 'danger' }]} + /> + } + actions={[{ label: 'Investigate', intent: 'warning' }]} + /> + } + /> + } + /> + + ), +}; + +export const Sizes: Story = { + render: () => ( + + + + + + + ), +}; + +export const Bordered: Story = { + args: { + label: 'Bordered row', + description: 'flat={false} renders an intent-coloured border', + intent: 'warning', + flat: false, + leading: , + }, +}; + +export const Square: Story = { + args: { + label: 'Square row', + description: 'rounded={false} removes the corner radius', + intent: 'info', + rounded: false, + leading: , + }, +}; + +export const WithEffects: Story = { + args: { + label: 'Effects on label and description', + description: 'Description with custom effect', + intent: 'info', + labelEffect: { weight: 'bold', uppercase: true, spaced: 1 }, + descriptionEffect: { italic: true, opacity: 0.8 }, + effect: { + gradient: { + colors: { 0: 'info:darken:5', 100: 'transparent' }, + direction: 'to right', + }, + }, + leading: , + }, +}; + +export const Clickable: Story = { + args: { + label: 'Customer Support · zendesk-ticket-sync', + description: 'Avg duration scored 3.2 (threshold 3.0)', + intent: 'warning', + onClick: () => alert('Row clicked'), + leading: , + }, +}; + +export const NoStrip: Story = { + args: { + label: 'Customer Onboarding · onboard-new-customer', + description: 'No issues — operating within baseline', + intent: 'success', + showStrip: false, + leading: , + }, +}; + +export const Transparent: Story = { + args: { + label: 'Inventory Management · stock-sync', + description: 'Operating normally — strip only, no tinted background', + intent: 'info', + transparent: true, + leading: , + }, +}; + +export const NoWrap: Story = { + args: { + label: 'Payment Processing · stripe-webhook-receiver-fallback', + description: + 'Avg duration 4.7s exceeded 3.5s threshold over the 14-period rolling baseline measured by the mad detector · just now', + intent: 'danger', + wrap: false, + leading: , + actions: [{ label: 'Investigate', intent: 'danger' }], + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const Disabled: Story = { + args: { + label: 'Disabled row', + description: 'No actions allowed', + intent: 'warning', + disabled: true, + leading: , + actions: [{ label: 'Investigate' }], + }, +}; diff --git a/src/stories/Statistic/Statistic.stories.tsx b/src/stories/Statistic/Statistic.stories.tsx index ad0c4b16..612fa99a 100644 --- a/src/stories/Statistic/Statistic.stories.tsx +++ b/src/stories/Statistic/Statistic.stories.tsx @@ -5,7 +5,7 @@ import { ReqoreControlGroup } from '../../index'; import { StoryMeta } from '../utils'; const meta = { - title: 'Data Display/Statistic/Stories', + title: 'Display/Statistic/Stories', component: ReqoreStatistic, } as StoryMeta; From 4b2665d2af34a51b32994e9c4f77ba162a35c873 Mon Sep 17 00:00:00 2001 From: foxhoundn Date: Wed, 6 May 2026 14:46:20 +0200 Subject: [PATCH 2/2] feat: add raised effect to various components - Implemented a subtle 3D "raised" effect for the following components: - Callout - EmptyState - EntityRow - FeatureCard - Message - Panel - SeverityRow - Statistic - Button - Updated tests to cover the new raised effect for each component. - Enhanced component documentation to include the raised prop and its usage. - Added stories for raised variants in Storybook for visual testing. --- __tests__/Callout.test.tsx | 14 +++++++ __tests__/EmptyState.test.tsx | 14 +++++++ __tests__/EntityRow.test.tsx | 14 +++++++ __tests__/FeatureCard.test.tsx | 14 +++++++ __tests__/Message.test.tsx | 37 +++++++++++++++++++ __tests__/SeverityRow.test.tsx | 14 +++++++ __tests__/Statistic.test.tsx | 14 +++++++ __tests__/button.test.tsx | 14 +++++++ __tests__/panel.test.tsx | 12 ++++++ src/components/Button/index.tsx | 11 ++++++ src/components/COMPONENTS.md | 18 ++++----- src/components/Callout/index.tsx | 15 +++++++- src/components/EmptyState/index.tsx | 8 ++++ src/components/EntityRow/index.tsx | 13 ++++++- src/components/FeatureCard/index.tsx | 15 +++++++- src/components/Message/index.tsx | 6 +++ src/components/Notifications/notification.tsx | 5 +++ src/components/Panel/index.tsx | 16 +++++++- src/components/SeverityRow/index.tsx | 13 ++++++- src/components/Statistic/index.tsx | 19 ++++++++-- src/stories/Button/Button.stories.tsx | 8 ++++ src/stories/Callout/Callout.stories.tsx | 12 ++++++ src/stories/EmptyState/EmptyState.stories.tsx | 10 +++++ src/stories/EntityRow/EntityRow.stories.tsx | 12 ++++++ .../FeatureCard/FeatureCard.stories.tsx | 12 ++++++ src/stories/Message/Message.stories.tsx | 10 +++++ src/stories/Panel/Panel.stories.tsx | 10 +++++ .../SeverityRow/SeverityRow.stories.tsx | 12 ++++++ src/stories/Statistic/Statistic.stories.tsx | 24 ++++++++++++ src/styles.ts | 15 ++++++++ 30 files changed, 392 insertions(+), 19 deletions(-) create mode 100644 __tests__/Message.test.tsx diff --git a/__tests__/Callout.test.tsx b/__tests__/Callout.test.tsx index 12727c3b..ed34d085 100644 --- a/__tests__/Callout.test.tsx +++ b/__tests__/Callout.test.tsx @@ -297,3 +297,17 @@ test('Renders disabled', () => { expect(document.querySelectorAll('.reqore-callout').length).toBe(1); }); + +test('Renders with raised effect', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-callout').length).toBe(1); +}); diff --git a/__tests__/EmptyState.test.tsx b/__tests__/EmptyState.test.tsx index b4aa77b6..2c89f21f 100644 --- a/__tests__/EmptyState.test.tsx +++ b/__tests__/EmptyState.test.tsx @@ -279,3 +279,17 @@ test('Renders with all features', () => { ); expect(document.querySelectorAll('.reqore-empty-state-actions').length).toBe(1); }); + +test('Renders with raised effect', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-empty-state').length).toBe(1); +}); diff --git a/__tests__/EntityRow.test.tsx b/__tests__/EntityRow.test.tsx index b01f2790..ec92a1e0 100644 --- a/__tests__/EntityRow.test.tsx +++ b/__tests__/EntityRow.test.tsx @@ -308,3 +308,17 @@ test('Renders with wrap=true by default', () => { expect(document.querySelectorAll('.reqore-entity-row-description').length).toBe(1); }); + +test('Renders with raised effect', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-entity-row').length).toBe(1); +}); diff --git a/__tests__/FeatureCard.test.tsx b/__tests__/FeatureCard.test.tsx index a3ff880d..803adde2 100644 --- a/__tests__/FeatureCard.test.tsx +++ b/__tests__/FeatureCard.test.tsx @@ -245,3 +245,17 @@ test('Auto-detects interactive when onClick is provided', () => { fireEvent.click(document.querySelector('.reqore-feature-card')!); expect(handleClick).toHaveBeenCalledTimes(1); }); + +test('Renders with raised effect', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-feature-card').length).toBe(1); +}); diff --git a/__tests__/Message.test.tsx b/__tests__/Message.test.tsx new file mode 100644 index 00000000..ca66c512 --- /dev/null +++ b/__tests__/Message.test.tsx @@ -0,0 +1,37 @@ +import { render } from '@testing-library/react'; +import { + ReqoreContent, + ReqoreLayoutContent, + ReqoreMessage, + ReqoreUIProvider, +} from '../src'; + +test('Renders properly', () => { + render( + + + + Heads up + + + + ); + + expect(document.body.textContent).toContain('Heads up'); +}); + +test('Renders with raised effect', () => { + render( + + + + + Raised + + + + + ); + + expect(document.body.textContent).toContain('Raised'); +}); diff --git a/__tests__/SeverityRow.test.tsx b/__tests__/SeverityRow.test.tsx index f1b2dccf..6a3a8748 100644 --- a/__tests__/SeverityRow.test.tsx +++ b/__tests__/SeverityRow.test.tsx @@ -330,3 +330,17 @@ test('Renders with wrap=true by default', () => { expect(document.querySelectorAll('.reqore-severity-row-description').length).toBe(1); }); + +test('Renders with raised effect', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-severity-row').length).toBe(1); +}); diff --git a/__tests__/Statistic.test.tsx b/__tests__/Statistic.test.tsx index 9ae9ef61..6e038d2b 100644 --- a/__tests__/Statistic.test.tsx +++ b/__tests__/Statistic.test.tsx @@ -316,3 +316,17 @@ test('Renders as interactive when onClick is provided', () => { expect(handleClick).toHaveBeenCalledTimes(1); }); + +test('Renders with raised effect', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-statistic').length).toBe(1); +}); diff --git a/__tests__/button.test.tsx b/__tests__/button.test.tsx index 1e114776..e79166ad 100644 --- a/__tests__/button.test.tsx +++ b/__tests__/button.test.tsx @@ -215,3 +215,17 @@ test('Tooltip on