diff --git a/.tasks/NEW_COMPONENT.md b/.tasks/NEW_COMPONENT.md index ddd0820d..2bca8671 100644 --- a/.tasks/NEW_COMPONENT.md +++ b/.tasks/NEW_COMPONENT.md @@ -180,3 +180,442 @@ If you cannot find a component idea that is genuinely useful and fits the librar - do not implement anything - instead produce a shortlist with pros/cons and stop + +--- + +# Appendix A — Repo idioms & gotchas (read before Step 4) + +These are concrete patterns distilled from real components in this repo (`ReqoreStatistic`, `ReqoreProgress`, `ReqoreFeatureCard`, `ReqoreCallout`, `ReqorePanel`, `ReqoreSeverityRow`, `ReqoreEntityRow`). Every one of them was a real review correction that should not have to happen twice. + +## A.1 Prop naming — match the library, do not invent + +| Use this | Not this | Why | +|---|---|---| +| `label` | `title` | `title` is an HTML reserved attribute. Every existing Reqore component (`Panel`, `Button`, `FeatureCard`, `Statistic`, `Tier`) uses `label`. | +| `description` | `subtitle`, `subLabel`, `secondary` | Used consistently across `Panel`, `FeatureCard`, `EmptyState`. | +| `transparent: boolean` | `tinted`, `noTint`, `withBg` | Polarity matches `ReqorePanel` / `ReqoreStatistic`. Default `false`; pass `true` to drop the surface. | +| `flat: boolean` | `bordered`, `outlined` | Default rendering shows a border; `flat={true}` drops it. Same polarity as Panel/Button/etc. | +| `rounded: boolean` | `square`, `pill` (unless explicitly a pill component) | Default `true`; `rounded={false}` makes it square. | +| `badge: TReqoreBadge \| TReqoreBadge[]` | `badges`, `tag`, `chip` | Reuse `Button.TReqoreBadge`. Render via `ButtonBadge`. | +| `actions: IReqoreXxxAction[]` | `buttons`, `controls` | Each action extends `Omit` plus an optional `label` field. Same as `Panel.actions`. | +| `effect`, `labelEffect`, `descriptionEffect`, `metadataEffect` | `style`, `theme`, `appearance` | One `*Effect` prop per text-bearing element. The wrapper takes `effect`. | +| `intent: TReqoreIntent` | `severity`, `level`, `kind` | Always use the `IReqoreIntent` contract. Map domain concepts (severity, status) to intents in the consumer. | +| `disabled`, `tooltip`, `customTheme`, `inheritCustomTheme`, `fluid`, `fixed`, `size` | anything else | Inherit from the standard global type contracts (see A.2). | + +**Rule of thumb**: before naming a prop, grep `src/components/Panel/index.tsx` and `src/components/Button/index.tsx` for the closest concept. If those use a name, you must use the same name. + +## A.2 Standard contract checklist + +Every interactive surface component should extend the matching contracts so consumers get the same prop bag everywhere: + +```ts +export interface IReqoreXxxProps + extends Omit, 'title'>, + IReqoreDisabled, + IReqoreIntent, + IWithReqoreCustomTheme, + IWithReqoreEffect, + IWithReqoreFlat, + IWithReqoreFluid, + IWithReqoreSize, + IWithReqoreTooltip { + // Component-specific props go here. +} +``` + +Add `IWithReqoreFixed` for components that can be used in flex layouts where shrinking is sometimes wrong. + +The `Omit, 'title'>` is critical — without it, callers can pass `title='...'` (the HTML attribute) which collides with any `title` field you might tempt yourself into adding. Block it explicitly. + +## A.3 Required structural patterns + +```ts +const ReqoreXxx = memo( + forwardRef( + ({ /* destructure all */ }, ref) => { + const theme = useReqoreTheme( + 'main', + customTheme, + intent, // pass intent only when the component should colour-shift + undefined, + inheritCustomTheme, + ); + + return ( + + {/* body */} + + ); + } + ) +); + +export default ReqoreXxx; +``` + +- **`memo` + `forwardRef` are non-negotiable** — without `forwardRef`, parents can't measure / scroll to / focus the component. +- **`ReqoreTooltipComponent` is the wrapper that gives every component a free `tooltip` prop.** Using it also propagates `effect`, `customTheme`, `disabled` correctly. Never wire tooltip yourself. +- **`useReqoreTheme(...)`** for theme resolution. Don't read theme directly from context; the hook applies intent + customTheme correctly. +- **`className` always merges with the existing one** and includes a `.reqore-` hook for tests. + +## A.4 Styled components — transient props + +Styled-components forwards all unknown props as DOM attributes. Anything that's a styling-only flag MUST be a transient prop (`$prefix`) so it doesn't leak to the DOM: + +```ts +interface IStyledXxxProps { + theme: IReqoreTheme; + size: TSizes; + $fluid?: boolean; // ← transient (styling-only) + $intent?: TReqoreIntent; // ← transient + $tinted?: boolean; // ← transient + flat?: boolean; // not transient — also a real prop on the public API + rounded?: boolean; // same +} +``` + +Pass them through with the `$` prefix at the render site: + +```tsx + +``` + +## A.5 Sizing — never hardcode pixels + +Use the size lookup tables in `constants/sizes`: + +- `PADDING_FROM_SIZE[size]` for content padding +- `RADIUS_FROM_SIZE[size]` for border radius +- `TEXT_FROM_SIZE[size]` / `CONTROL_TEXT_FROM_SIZE[size]` for font size +- `ICON_FROM_SIZE[size]` for icon size + +If your component has its own scale (e.g. an icon tile that's bigger than a regular icon), define a per-component table: + +```ts +const ICON_TILE_SIZE_FROM_SIZE: Record = { + micro: 18, tiny: 22, small: 26, normal: 32, big: 40, huge: 48, massive: 56, +}; +``` + +Don't hardcode `28px` in the styled component. + +## A.6 Colour helpers — use the toolkit, not hex + +```ts +import { + changeLightness, + changeDarkness, + getMainBackgroundColor, + getReadableColor, + getColorFromMaybeString, +} from '../../helpers/colors'; +import { rgba } from 'polished'; +``` + +- `theme.intents[intent]` for intent-coloured fills +- `changeLightness(getMainBackgroundColor(theme), 0.08)` for subtle borders +- `rgba(theme.intents[intent], 0.06)` for tinted backgrounds +- `getReadableColor(theme, undefined, undefined, true)` for foreground that adapts to the surface + +**Reqore colour shorthand strings** (work anywhere a `TReqoreEffectColor` is accepted): + +``` +'main:lighten:5' // 5 steps lighter than theme.main +'success:darken:2' // 2 steps darker than theme.intents.success +'info:lighten:2:0.6' // intent.info, 2 steps lighter, 0.6 opacity +``` + +Use these for design tokens like dimmed chip surfaces (`customTheme={{ main: 'main:lighten:5' }}`) instead of hardcoded hex. + +## A.7 Effect prop trifecta — every text-bearing element gets one + +For a component with `label` + `description` + `metadata`: + +```ts +labelEffect?: IReqoreEffect; +descriptionEffect?: IReqoreEffect; +metadataEffect?: IReqoreEffect; +effect?: IReqoreEffect; // applied to the wrapper for backgrounds/gradients +``` + +Pass them to the inner elements with sensible defaults merged via spread: + +```tsx +{description} +``` + +That way the consumer's effect always wins on conflicts but the default opacity etc. carries through for unset fields. + +The wrapper itself uses `styled(StyledEffect)` so the `effect` prop applies to the surface (gradients, glows, frost, drop-shadow filters): + +```ts +const StyledXxx = styled(StyledEffect)` /* ... */ `; +``` + +## A.8 Badges — reuse `ButtonBadge`, never roll your own + +```tsx +import ReqoreButton, { ButtonBadge, TReqoreBadge } from '../Button'; + +// In your styled label row: +{hasBadge && } +``` + +**`margin='none'` is critical** when the badge is inside a flex container with its own `gap`. The default `margin='left'` adds a `ReqoreSpacer` which double-spaces inside a flex parent and creates visible gaps. Use `'none'` and let the flex `gap` handle spacing. + +## A.9 Truncation / `wrap={false}` — cascade ellipsis, never apply to the wrapper + +The CSS `text-overflow: ellipsis` only takes effect on the actual text-bearing element. Applying it to a wrapping `
` just clips the inner text without rendering the `…` glyph. The correct pattern: + +```ts +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%; + } + `} +`; +``` + +Then wrap each truncatable element with ``. The `& > *` selector cascades into the inner `` / `` / ``. + +For multi-element rows (label + leading tag + badge), only wrap the LABEL in `StyledTextSlot` so the leading tag and badge keep their natural width while the label ellipsizes. + +## A.10 Border behaviour — honour `intent` when present + +```ts +border: ${({ flat, theme, intent }) => + flat + ? 'none' + : `1px solid ${changeLightness( + intent ? theme.intents[intent] : getMainBackgroundColor(theme), + 0.08 + )}`}; +``` + +When the consumer passes `intent='danger'` and `flat={false}`, the border should be danger-coloured at 0.08 lightness (subtle). This was a real bug in the original `ReqoreCallout` — the border used the theme bg regardless of intent. + +## A.11 The `raised` 3D effect — shared helper + +If your component has a card-like surface, accept a `raised?: boolean` prop and apply the `RaisedElement` helper from `src/styles.ts`: + +```ts +import { RaisedElement } from '../../styles'; + +// In the styled component: +${({ raised, flat }) => raised && flat !== false && RaisedElement} +``` + +`flat !== false` gate (or per-component equivalent) suppresses the highlight when there's already a border, since the border provides surface definition. The helper itself: + +```ts +export const RaisedElement = css` + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.06), + inset 0 -1px 0 rgba(0, 0, 0, 0.22); +`; +``` + +Theme-neutral additive overlays — works on dark or light themes without per-theme tuning. + +## A.12 Don't reinvent existing `IReqoreEffect` features + +`IReqoreEffect` already supports: + +- `gradient` (linear/radial, animated, with intent border colour) +- `glow` (colour, blur, opacity, inset, when: hover/focus/active) +- `frost` (frosted glass with backdrop-filter) +- `weight`, `italic`, `underline`, `uppercase`, `spaced` (text styling) +- `opacity`, `blur`, `grayscale`, `sepia`, `invert`, `brightness`, `contrast`, `saturate` + +If your component needs glow / frost / gradient — pipe `effect` through. Don't add a parallel prop. + +If a shorthand makes sense (e.g. `ReqoreIcon.glow: boolean | TReqoreEffectColor | { color, blur, opacity }`), add it as convenience, but resolve internally to the existing `IReqoreEffect.glow` semantics. + +## A.13 Class hooks for testability + +Always include a `.reqore-` class on the root, plus per-part classes on every meaningful sub-element. Tests query by these class names: + +``` +.reqore-xxx // root +.reqore-xxx-icon // leading icon if any +.reqore-xxx-label // label +.reqore-xxx-description // description +.reqore-xxx-metadata // tertiary text +.reqore-xxx-actions // action group +.reqore-xxx-strip // any decorative strip / accent +.reqore-xxx-body // content wrapper +``` + +Used in `__tests__/Xxx.test.tsx`: + +```ts +expect(document.querySelector('.reqore-xxx-label')!.textContent).toContain('...'); +expect(document.querySelectorAll('.reqore-xxx-actions').length).toBe(1); +``` + +## A.14 Required stories matrix + +Every new surface component should have these stories at minimum (under `src/stories//.stories.tsx`): + +- `Basic` — minimal config +- `WithLabel` / `WithBadge` / `WithIcon` — each major optional prop demonstrated separately +- `Sizes` — `tiny / small / normal / big` (or larger if relevant) in a `ReqoreControlGroup` +- `Intents` — every intent in a row (`Object.keys(DEFAULT_INTENTS).map(...)`) +- `Bordered` — `flat={false}` (the bordered case is often the inverse of the default; show it) +- `Square` — `rounded={false}` +- `Transparent` — `transparent={true}` +- `Disabled` — `disabled={true}` +- `Tooltip` — `tooltip='...'` so the prop is clearly inheritable +- `Clickable` — `onClick` to demonstrate hover/lift/cursor +- `WithEffects` — combined `effect` + `labelEffect` + `descriptionEffect` +- `CustomTheme` — `customTheme={{ main: '#...' }}` +- `Fixed` — when applicable +- `Raised` — when supported (see A.11) +- `NoWrap` (or `Truncated`) — when supported (with a narrow-width decorator so the ellipsis is visible) + +**Story title pattern**: `'Display//Stories'` or `'Data Display//Stories'`. Match the existing folder grouping. + +## A.15 Storybook layout pitfall + +Reqore's `.storybook/preview.tsx` sets `parameters.layout = 'fullscreen'` globally. **Do not override it to `'padded'` per-story** — that's Storybook's built-in layout that adds white margins around the canvas, which fights the dark Reqore canvas and gives stories an unintentional white background. + +If your story needs a constrained width (e.g. for `NoWrap` to actually demonstrate ellipsis), use a `decorator` instead: + +```ts +decorators: [ + (Story) => ( +
+ +
+ ), +], +``` + +Don't touch `parameters.layout`. + +## A.16 Required tests matrix + +Mirror the stories matrix in `__tests__/.test.tsx`. At minimum: + +- renders root + label + description +- doesn't render optional parts when not provided (negative cases) +- renders with each intent +- renders with each size +- renders with `flat={false}` (border case) +- renders with `rounded={false}` +- renders with `transparent` +- renders with `effect`, `labelEffect`, `descriptionEffect` together +- `onClick` fires when set +- `disabled` short-circuits interaction (when applicable) +- badge as string + as object + as array (each renders the right number of `.reqore-button-badge` elements) +- truncation (`wrap={false}`) — both the negative (wraps by default) and positive case +- `raised` smoke test when supported + +Standard render setup: + +```tsx +render( + + + + + + + +); +``` + +Don't omit the providers — some hooks (`useReqoreTheme`, `useReqoreProperty`) silently return undefined without them and the test passes for the wrong reason. + +## A.17 Index export + COMPONENTS.md + +Two non-optional steps for shipping: + +1. Add to `src/index.tsx` in alphabetical order: + ```ts + export { default as ReqoreXxx } from './components/Xxx'; + ``` + Use `default` if your component is exported as default; named otherwise. + +2. Add a one-line entry to `src/components/COMPONENTS.md` describing the prop catalogue. Match the depth of existing entries — list the standard prop bag (intent, size, flat, rounded, fluid, transparent, customTheme, tooltip, disabled, effect/labelEffect/descriptionEffect) plus what's component-specific. + +Both happen in the same PR as the component. + +## A.18 Don't drop `children` when supporting structured content + +Real bug from `ReqoreCallout`: when both `label` and `children` could be passed, the structured-content branch (label/description) was rendered and `children` was silently dropped. Result: `{detailedMessage}` rendered just "Heads up" with no body. + +Fix: when supporting both forms, ensure all paths render content. Either: +- Use `children` as a fallback for `description` when `description` is not set, or +- Document explicitly that `description` takes precedence and drop `children` in that branch (but log a dev-mode warning), or +- Render both stacked. + +Pick one and document it. Silently dropping content is the worst option. + +## A.19 Component composition — reuse, don't reinvent + +Before writing new layout primitives, check if these existing components can be the building blocks: + +| Need | Use | +|---|---| +| KPI tile (number + label + trend) | `ReqoreStatistic` | +| Progress bar (with optional target marker) | `ReqoreProgress` | +| Inline alert / notice | `ReqoreCallout` (label + description + icon + onClose) | +| List of issues / alerts (severity strip + label + actions) | `ReqoreSeverityRow` | +| List of entities / items (icon tile + label + metadata + actions) | `ReqoreEntityRow` | +| Card with marker + label + description | `ReqoreFeatureCard` | +| Pricing tier card | `ReqoreTier` | +| Empty state placeholder | `ReqoreEmptyState` | +| Container with header + footer + actions | `ReqorePanel` | +| Icon with optional glow | `ReqoreIcon` (use `glow` prop) | +| Drawer / side panel | `ReqoreDrawer` (extends `ReqorePanel` — same `bottomActions`, `actions`, `label`, `badge`, `icon`) | +| Two-column key/value table | `ReqoreKeyValueTable` | +| Vertical event list | `ReqoreTimeline` | +| Tag / chip | `ReqoreTag` (use `customTheme={{ main: 'main:lighten:5' }}` for dimmed neutral chips, NOT `intent='muted'` which often looks too dark) | + +If your "new component" is mostly composition of these, ship it as a thin composer in the consumer repo first. Only promote to Reqore once it's used in 2+ places with the same shape. + +--- + +# Appendix B — Common review corrections (memorise these) + +These are real fixes that came back from review. Avoid them in v1: + +1. **`title` instead of `label`** → reviewer will request rename. Just use `label`. +2. **`tinted` / `noTint`** → reviewer will request `transparent`. Use `transparent`. +3. **`trailingTitle` slot for badges** → reviewer will request `badge: TReqoreBadge | TReqoreBadge[]`. Use the standard typing + `ButtonBadge`. +4. **Bottom Close button when there's already a top-right `X`** → drop the redundant Close. The drawer/modal's built-in close is the only Close affordance. +5. **`hidable` enabled on a drawer that doesn't need a collapse** → drop it. Renders an extra `>` button stacked above the close `X` and looks weird. +6. **Bare custom styled headings instead of `` / eyebrow ``** → use existing typography primitives, with `effect={{ uppercase, spaced, weight: 'bold', opacity: 0.5 }}` for eyebrow/section labels. +7. **Action buttons inside the body when `bottomActions` exists on the panel/drawer** → use `bottomActions` so the bar sticks at the bottom and doesn't leave a void. +8. **Action button when the row itself can be clickable** → make the row clickable (``) instead of a trailing icon button. +9. **`labelEffect.gradient` on every panel** → resist the temptation. One subtle gradient on the page title is enough. Multiple gradients fight for attention. +10. **`(N)` count in segmented control labels** → use the segmented item's `badge` field instead. Each item supports `badge: TReqoreBadge`. +11. **`intent='muted'` for "neutral metadata" chips** → too dark on most themes. Use `customTheme={{ main: 'main:lighten:5' }}` for a visible-but-dimmed chip surface. +12. **Action that fires `onAction(name, payload)` with no destination** → either wire it up, mark it `disabled` with `tooltip='Coming soon'`, or remove the button. Don't ship buttons that do nothing. +13. **`min-width: 0` missing on the wrapping flex item** → ellipsis won't trigger; the inner element won't shrink below its content. Always set `min-width: 0` on the flex item that contains a truncatable element. +14. **Forgetting `minimal` on `ReqoreDrawer`** → the drawer's panel chrome is too heavy without it. Convention here is to always pass `minimal`. diff --git a/__tests__/Accordion.test.tsx b/__tests__/Accordion.test.tsx index 83718cc3..4d4b9ecb 100644 --- a/__tests__/Accordion.test.tsx +++ b/__tests__/Accordion.test.tsx @@ -7,9 +7,9 @@ import { } from '../src'; const basicItems = [ - { title: 'Item 1', content: 'Content 1' }, - { title: 'Item 2', content: 'Content 2' }, - { title: 'Item 3', content: 'Content 3' }, + { label: 'Item 1', content: 'Content 1' }, + { label: 'Item 2', content: 'Content 2' }, + { label: 'Item 3', content: 'Content 3' }, ]; test('Renders with items', () => { @@ -76,8 +76,8 @@ test('Respects isOpen default state', () => { @@ -143,8 +143,8 @@ test('Renders with icons', () => { @@ -162,8 +162,8 @@ test('Renders with badges', () => { @@ -171,7 +171,7 @@ test('Renders with badges', () => { ); - expect(document.querySelectorAll('.reqore-accordion-badge').length).toBe(1); + expect(document.querySelectorAll('.reqore-button-badge').length).toBe(1); }); test('Disabled items cannot be toggled', () => { @@ -181,8 +181,8 @@ test('Disabled items cannot be toggled', () => { @@ -304,8 +304,8 @@ test('Renders with item-level intents', () => { @@ -352,7 +352,7 @@ test('Renders custom React content', () => { Custom element
, isOpen: true, }, diff --git a/__tests__/Callout.test.tsx b/__tests__/Callout.test.tsx index ed34d085..14916ad4 100644 --- a/__tests__/Callout.test.tsx +++ b/__tests__/Callout.test.tsx @@ -311,3 +311,75 @@ test('Renders with raised effect', () => { expect(document.querySelectorAll('.reqore-callout').length).toBe(1); }); + +test('Renders with padded=false', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-callout').length).toBe(1); +}); + +test('Renders with padded="horizontal"', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-callout').length).toBe(1); +}); + +test('Renders with padded="vertical"', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-callout').length).toBe(1); +}); + +test('Renders with custom paddingSize', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-callout').length).toBe(1); +}); + +test('Renders with padded=false + accent reserves accentSize', () => { + // Even when padded={false} is set, the side that hosts the accent strip + // must reserve accentSize so content does not collide with the strip. + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-callout').length).toBe(1); +}); diff --git a/__tests__/EntityRow.test.tsx b/__tests__/EntityRow.test.tsx index ec92a1e0..a454f0fa 100644 --- a/__tests__/EntityRow.test.tsx +++ b/__tests__/EntityRow.test.tsx @@ -309,6 +309,127 @@ test('Renders with wrap=true by default', () => { expect(document.querySelectorAll('.reqore-entity-row-description').length).toBe(1); }); +test('Hides the icon tile by default when transparent', () => { + render( + + + + + + + + ); + + // No tinted tile in transparent mode by default + expect(document.querySelectorAll('.reqore-entity-row-icon-tile').length).toBe(0); + // Bare icon is still rendered + expect(document.querySelectorAll('.reqore-entity-row-icon').length).toBe(1); +}); + +test('Shows the icon tile on a transparent row when iconHasBackground={true}', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-entity-row-icon-tile').length).toBe(1); +}); + +test('Renders with iconHasBackground=false (bare icon, no tile)', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-entity-row-icon-tile').length).toBe(0); + expect(document.querySelectorAll('.reqore-entity-row-icon').length).toBe(1); +}); + +test('Renders with padded=false', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-entity-row').length).toBe(1); +}); + +test('Renders with padded="horizontal"', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-entity-row').length).toBe(1); +}); + +test('Renders with padded="vertical"', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-entity-row').length).toBe(1); +}); + +test('Renders with custom paddingSize', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-entity-row').length).toBe(1); +}); + +test('Renders with iconHasBackground=true by default (tile shown)', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-entity-row-icon-tile').length).toBe(1); +}); + test('Renders with raised effect', () => { render( diff --git a/__tests__/FeatureCard.test.tsx b/__tests__/FeatureCard.test.tsx index 803adde2..c4b59b7f 100644 --- a/__tests__/FeatureCard.test.tsx +++ b/__tests__/FeatureCard.test.tsx @@ -259,3 +259,59 @@ test('Renders with raised effect', () => { expect(document.querySelectorAll('.reqore-feature-card').length).toBe(1); }); + +test('Renders with padded=false', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-feature-card').length).toBe(1); +}); + +test('Renders with padded="horizontal"', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-feature-card').length).toBe(1); +}); + +test('Renders with padded="vertical"', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-feature-card').length).toBe(1); +}); + +test('Renders with custom paddingSize', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-feature-card').length).toBe(1); +}); diff --git a/__tests__/SeverityRow.test.tsx b/__tests__/SeverityRow.test.tsx index 6a3a8748..6b789a24 100644 --- a/__tests__/SeverityRow.test.tsx +++ b/__tests__/SeverityRow.test.tsx @@ -344,3 +344,59 @@ test('Renders with raised effect', () => { expect(document.querySelectorAll('.reqore-severity-row').length).toBe(1); }); + +test('Renders with padded=false', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-severity-row').length).toBe(1); +}); + +test('Renders with padded="horizontal"', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-severity-row').length).toBe(1); +}); + +test('Renders with padded="vertical"', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-severity-row').length).toBe(1); +}); + +test('Renders with custom paddingSize', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-severity-row').length).toBe(1); +}); diff --git a/__tests__/Statistic.test.tsx b/__tests__/Statistic.test.tsx index 6e038d2b..4c2e1918 100644 --- a/__tests__/Statistic.test.tsx +++ b/__tests__/Statistic.test.tsx @@ -330,3 +330,65 @@ test('Renders with raised effect', () => { expect(document.querySelectorAll('.reqore-statistic').length).toBe(1); }); + +test('Renders with padded=false', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-statistic').length).toBe(1); +}); + +test('Renders with padded="horizontal"', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-statistic').length).toBe(1); +}); + +test('Renders with padded="vertical"', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-statistic').length).toBe(1); +}); + +test('Renders with custom paddingSize', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-statistic').length).toBe(1); +}); diff --git a/__tests__/Testimonial.test.tsx b/__tests__/Testimonial.test.tsx new file mode 100644 index 00000000..d199b90c --- /dev/null +++ b/__tests__/Testimonial.test.tsx @@ -0,0 +1,284 @@ +import { fireEvent, render } from '@testing-library/react'; +import { + ReqoreContent, + ReqoreLayoutContent, + ReqoreTestimonial, + ReqoreUIProvider, +} from '../src'; + +const renderTestimonial = (ui: React.ReactNode) => + render( + + + {ui} + + + ); + +test('Renders with quote', () => { + renderTestimonial(); + + expect(document.querySelectorAll('.reqore-testimonial').length).toBe(1); + expect(document.querySelector('.reqore-testimonial-quote')!.textContent).toContain( + 'Best library' + ); +}); + +test('Renders with children when no quote prop', () => { + renderTestimonial(Children quote); + + expect(document.querySelector('.reqore-testimonial-quote')!.textContent).toContain( + 'Children quote' + ); +}); + +test('Renders with author and role', () => { + renderTestimonial( + + ); + + const footer = document.querySelector('.reqore-testimonial-footer')!; + expect(footer).toBeTruthy(); + expect(footer.querySelector('.reqore-entity-row-label')!.textContent).toContain('Avery Chen'); + expect(footer.querySelector('.reqore-entity-row-description')!.textContent).toContain( + 'Lead Engineer' + ); +}); + +test('Renders with avatar icon', () => { + renderTestimonial( + + ); + + // Bare icon — no tinted icon-tile + expect(document.querySelectorAll('.reqore-entity-row-icon-tile').length).toBe(0); + expect(document.querySelectorAll('.reqore-testimonial-footer .reqore-icon').length).toBe(1); +}); + +test('Renders with avatar image', () => { + renderTestimonial( + + ); + + const img = document.querySelector('.reqore-testimonial-footer img')!; + expect(img).toBeTruthy(); + expect(img.getAttribute('src')).toBe('https://example.com/avatar.png'); +}); + +test('Does not render avatar when neither avatar nor avatarIcon is provided', () => { + renderTestimonial(); + + expect(document.querySelectorAll('.reqore-testimonial-footer .reqore-icon').length).toBe(0); + expect(document.querySelectorAll('.reqore-testimonial-footer img').length).toBe(0); +}); + +test('Renders with quote icon by default', () => { + renderTestimonial(); + + expect(document.querySelectorAll('.reqore-testimonial-quote-icon').length).toBe(1); +}); + +test('Hides the quote icon when showQuoteIcon is false', () => { + renderTestimonial(); + + expect(document.querySelectorAll('.reqore-testimonial-quote-icon').length).toBe(0); +}); + +test('Renders with rating', () => { + renderTestimonial(); + + expect(document.querySelectorAll('.reqore-testimonial-rating').length).toBe(1); +}); + +test('Does not render rating when not provided', () => { + renderTestimonial(); + + expect(document.querySelectorAll('.reqore-testimonial-rating').length).toBe(0); +}); + +test('Renders with badge (string)', () => { + renderTestimonial(); + + expect(document.querySelector('.reqore-button-badge')!.textContent).toContain('Verified'); +}); + +test('Renders with badge object', () => { + renderTestimonial( + + ); + + expect(document.querySelector('.reqore-button-badge')!.textContent).toContain('Customer'); +}); + +test('Renders with badge array', () => { + renderTestimonial( + + ); + + expect(document.querySelectorAll('.reqore-button-badge').length).toBe(2); +}); + +test('Renders with actions', () => { + renderTestimonial( + + ); + + expect(document.querySelectorAll('.reqore-testimonial-actions').length).toBe(1); + expect(document.querySelector('.reqore-testimonial-actions')!.textContent).toContain( + 'Read more' + ); +}); + +test('Does not render actions when not provided', () => { + renderTestimonial(); + + expect(document.querySelectorAll('.reqore-testimonial-actions').length).toBe(0); +}); + +test('Calls onClick when card is clicked', () => { + const handleClick = jest.fn(); + renderTestimonial(); + + fireEvent.click(document.querySelector('.reqore-testimonial')!); + expect(handleClick).toHaveBeenCalledTimes(1); +}); + +test('Renders with each intent', () => { + renderTestimonial( + <> + + + + + + ); + + expect(document.querySelectorAll('.reqore-testimonial').length).toBe(4); +}); + +test('Renders with different sizes', () => { + renderTestimonial( + <> + + + + + + ); + + expect(document.querySelectorAll('.reqore-testimonial').length).toBe(4); +}); + +test('Renders bordered with flat={false}', () => { + renderTestimonial(); + + expect(document.querySelectorAll('.reqore-testimonial').length).toBe(1); +}); + +test('Renders with rounded={false}', () => { + renderTestimonial(); + + expect(document.querySelectorAll('.reqore-testimonial').length).toBe(1); +}); + +test('Renders with transparent background', () => { + renderTestimonial(); + + expect(document.querySelectorAll('.reqore-testimonial').length).toBe(1); +}); + +test('Renders with effect / quoteEffect / authorEffect / roleEffect', () => { + renderTestimonial( + + ); + + expect(document.querySelectorAll('.reqore-testimonial').length).toBe(1); + expect(document.querySelectorAll('.reqore-testimonial-footer').length).toBe(1); +}); + +test('Renders disabled', () => { + const handleClick = jest.fn(); + renderTestimonial( + + ); + + expect(document.querySelectorAll('.reqore-testimonial').length).toBe(1); +}); + +test('Renders with raised effect', () => { + renderTestimonial(); + + expect(document.querySelectorAll('.reqore-testimonial').length).toBe(1); +}); + +test('Renders with wrap=false (single-line ellipsis)', () => { + renderTestimonial( + + ); + + expect(document.querySelectorAll('.reqore-testimonial-quote').length).toBe(1); +}); + +test('Does not render footer when no attribution provided', () => { + renderTestimonial(); + + expect(document.querySelectorAll('.reqore-testimonial-footer').length).toBe(0); +}); + +test('Renders with padded=false', () => { + renderTestimonial(); + + expect(document.querySelectorAll('.reqore-testimonial').length).toBe(1); +}); + +test('Renders with padded="horizontal"', () => { + renderTestimonial(); + + expect(document.querySelectorAll('.reqore-testimonial').length).toBe(1); +}); + +test('Renders with padded="vertical"', () => { + renderTestimonial(); + + expect(document.querySelectorAll('.reqore-testimonial').length).toBe(1); +}); + +test('Renders with custom paddingSize', () => { + renderTestimonial(); + + expect(document.querySelectorAll('.reqore-testimonial').length).toBe(1); +}); + +test('Renders with custom theme', () => { + renderTestimonial(); + + expect(document.querySelectorAll('.reqore-testimonial').length).toBe(1); +}); diff --git a/__tests__/panel.test.tsx b/__tests__/panel.test.tsx index d786c0b0..f9136723 100644 --- a/__tests__/panel.test.tsx +++ b/__tests__/panel.test.tsx @@ -234,3 +234,82 @@ test('Renders with raised effect', () => { expect(document.querySelectorAll('.reqore-panel').length).toBe(1); }); + +test('Renders with iconWithLabel placing icon next to label', () => { + render( + + + + Panel + + + + ); + + // When iconWithLabel is true, the icon must live INSIDE the label+badge row + // — i.e. it sits next to the label rather than to the left of the whole stack. + const labelRow = document.querySelector( + '.reqore-panel-title .reqore-icon' + ); + expect(labelRow).toBeTruthy(); + expect(document.querySelectorAll('.reqore-panel-title').length).toBe(1); +}); + +test('Renders with iconVerticalAlign=top', () => { + render( + + + + Panel + + + + ); + + expect(document.querySelectorAll('.reqore-panel-title').length).toBe(1); + expect(document.querySelectorAll('.reqore-icon').length).toBeGreaterThan(0); +}); + +test('Renders with iconVerticalAlign=bottom', () => { + render( + + + + Panel + + + + ); + + expect(document.querySelectorAll('.reqore-panel-title').length).toBe(1); + expect(document.querySelectorAll('.reqore-icon').length).toBeGreaterThan(0); +}); + +test('Renders with default iconVerticalAlign=center', () => { + render( + + + + Panel + + + + ); + + expect(document.querySelectorAll('.reqore-panel-title').length).toBe(1); +}); diff --git a/package.json b/package.json index d1acc2b0..4aa446de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qoretechnologies/reqore", - "version": "0.66.0", + "version": "0.67.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/Accordion/index.tsx b/src/components/Accordion/index.tsx index 46f96a12..e5c40de7 100644 --- a/src/components/Accordion/index.tsx +++ b/src/components/Accordion/index.tsx @@ -28,19 +28,19 @@ import { IWithReqoreTooltip, } from '../../types/global'; import { IReqoreIconName } from '../../types/icons'; -import { TReqoreBadge } from '../Button'; +import { ButtonBadge, TReqoreBadge } from '../Button'; import { IReqoreEffect, StyledEffect, TReqoreEffectColor } from '../Effect'; +import { ReqoreHeading } from '../Header'; import ReqoreIcon, { StyledIconWrapper } from '../Icon'; import { ReqoreSpan } from '../Span'; -import ReqoreTag, { IReqoreTagProps } from '../Tag'; -import ReqoreTagGroup from '../Tag/group'; import { ReqoreTooltipComponent } from '../TooltipComponent'; export interface IReqoreAccordionItem { /** Unique identifier for the item */ id?: string; /** Title displayed in the header */ - title: string; + label: string; + labelEffect?: IReqoreEffect; /** Content displayed when expanded */ content: React.ReactNode; /** Optional icon in the header */ @@ -161,7 +161,7 @@ const StyledAccordionHeader = styled.div` display: flex; align-items: center; gap: ${() => GAP_FROM_SIZE.normal}px; - padding: ${({ size }) => `${PADDING_FROM_SIZE[size]}px ${PADDING_FROM_SIZE[size] * 1.5}px`}; + padding: ${({ size }) => `${PADDING_FROM_SIZE[size] * 1.5}px ${PADDING_FROM_SIZE[size] * 1.5}px`}; cursor: pointer; user-select: none; transition: background-color 0.15s ease-out; @@ -191,12 +191,6 @@ const StyledAccordionHeader = styled.div` `} `; -const StyledAccordionHeaderTitle = styled.span` - flex: 1; - min-width: 0; - font-weight: 500; -`; - const StyledAccordionChevron = styled(StyledIconWrapper)<{ $isOpen?: boolean }>` transition: transform 0.2s ease-out !important; transform: rotate(${({ $isOpen }) => ($isOpen ? '180deg' : '0deg')}) !important; @@ -214,51 +208,13 @@ const StyledAccordionContentInner = styled.div<{ theme: IReqoreTheme; size: TSiz `; const StyledAccordionContentBody = styled.div<{ theme: IReqoreTheme; size: TSizes }>` - padding: ${({ size }) => `${PADDING_FROM_SIZE[size]}px ${PADDING_FROM_SIZE[size] * 1.5}px`}; + padding: ${({ size }) => `${PADDING_FROM_SIZE[size] * 3}px ${PADDING_FROM_SIZE[size] * 4.6}px`}; font-size: ${({ size }) => TEXT_FROM_SIZE[size]}px; color: ${({ theme }) => getReadableColor(theme, undefined, undefined, true)}; background-color: ${({ theme }) => rgba(changeDarkness(getMainBackgroundColor(theme), 0.03), 1)}; border-top: 1px solid ${({ theme }) => changeLightness(getMainBackgroundColor(theme), 0.05)}; `; -interface IBadgeProps { - content: TReqoreBadge | TReqoreBadge[]; - size: TSizes; -} - -const AccordionBadge = memo(({ size, content }: IBadgeProps) => { - const renderTag = useCallback( - (badge: TReqoreBadge, key: number) => ( - - ), - [size] - ); - - if (!content && content !== 0) { - return null; - } - - if (Array.isArray(content)) { - return ( - - {content.map((badge, index) => renderTag(badge, index))} - - ); - } - - return renderTag(content, 0); -}); - interface IAccordionItemRendererProps { item: IReqoreAccordionItem; index: number; @@ -336,15 +292,19 @@ const AccordionItemRenderer = memo( {item.icon && ( )} - - {item.title} - - {(item.badge || item.badge === 0) && } + + {item.label} + + {(item.badge || item.badge === 0) && } diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 3128f9fc..b4e9bb0a 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -114,9 +114,9 @@ export interface IReqoreButtonProps circle?: boolean; /** * Subtle 3D "raised" effect — inset top highlight + inset bottom shadow. - * Best paired with `flat={true}` (no border) and a non-`minimal`/non- - * `transparent` button so the surface is visible. Suppressed when a border - * is rendered (`flat={false}`) since the border already provides definition. + * Best paired with `flat={true}` (no border); suppressed when a border is + * rendered (`flat={false}`) since the border already provides definition, + * and suppressed when `transparent` since there is no surface to lift. */ raised?: boolean; } @@ -229,8 +229,7 @@ export const StyledButton = styled(StyledEffect)` ${InactiveIconScale}; - ${({ raised, flat, minimal, transparent }) => - raised && flat && !minimal && !transparent && RaisedElement} + ${({ raised, flat, transparent }) => raised && flat && !transparent && RaisedElement} ${({ readOnly, animate, active, theme, color, effect }) => !readOnly && !active diff --git a/src/components/COMPONENTS.md b/src/components/COMPONENTS.md index cf328245..e2366088 100644 --- a/src/components/COMPONENTS.md +++ b/src/components/COMPONENTS.md @@ -5,7 +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, multiple style variants (minimal, flat, transparent), and an optional `raised` prop that adds a subtle 3D inset highlight when paired with `flat`. | -| **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, raised (subtle 3D inset highlight when paired with `flat`), customTheme, tooltip, disabled, effect / labelEffect / descriptionEffect / contentEffect, and click handlers. | +| **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, raised (subtle 3D inset highlight when paired with `flat`), customTheme, tooltip, disabled, effect / labelEffect / descriptionEffect / contentEffect, `padded` (`true` \| `false` \| `'horizontal'` \| `'vertical'` — default `true`; the side adjacent to the accent strip still reserves room for it), `paddingSize` (`TSizes` — defaults to `size`), 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. | @@ -18,8 +18,8 @@ | **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. Optional `raised` adds a subtle 3D inset highlight to the placeholder surface. | -| **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, raised (subtle 3D inset highlight when paired with `flat`), 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, raised (subtle 3D inset highlight when paired with `flat`), customTheme, tooltip, disabled, interactive (auto-detected from `onClick`), effect / labelEffect / descriptionEffect / markerEffect, and wrap (single-line ellipsis on `false`). | +| **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, raised (subtle 3D inset highlight when paired with `flat`), customTheme, tooltip, disabled, effect / labelEffect / descriptionEffect / metadataEffect, wrap (single-line ellipsis on `false`), `iconHasBackground` (defaults to `true` on opaque rows and `false` when `transparent={true}` so the tile does not fight the transparent surface; pass an explicit boolean to override), `padded` (`true` \| `false` \| `'horizontal'` \| `'vertical'` — default `true`; controls which axes receive outer padding), `paddingSize` (`TSizes` — defaults to `size`; scales padding independently from text/icon scale), 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, raised (subtle 3D inset highlight when paired with `flat`), customTheme, tooltip, disabled, interactive (auto-detected from `onClick`), effect / labelEffect / descriptionEffect / markerEffect, wrap (single-line ellipsis on `false`), `padded` (`true` \| `false` \| `'horizontal'` \| `'vertical'` — default `true`), and `paddingSize` (`TSizes` — defaults to `size`). | | **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. | @@ -36,9 +36,9 @@ | **ReqoreModal** | Modal dialog that extends Drawer with centered positioning and ESC key handling. | | **ReqoreMultiSelect** | Multi-select input allowing users to add/remove items from a dropdown with customizable tagging. | | **ReqoreNavbar** | Header or footer navigation bar with customizable styling and position options. | -| **ReqoreNotificationsWrapper** | Container for managing toast notifications at fixed screen positions. | +| **ReqoreNotificationsWrapper** | Container for managing toast notifications at fixed screen positions. Individual notifications support `padded` (`true` \| `false` \| `'horizontal'` \| `'vertical'` — default `true`) and `paddingSize` (`TSizes` — defaults to `size`) to tune density. | | **ReqorePaging** | Pagination controls component with page buttons, load more, and scroll-based infinite loading options. | -| **ReqorePanel** | Container component with optional header, footer, actions, and resizable panels with breadcrumbs support. Optional `raised` adds a subtle 3D inset highlight when paired with `flat` (suppressed when an `intent` is set since the intent border already provides definition). | +| **ReqorePanel** | Container component with optional header, footer, actions, and resizable panels with breadcrumbs support. Optional `raised` adds a subtle 3D inset highlight when paired with `flat` (suppressed when an `intent` is set since the intent border already provides definition). Header layout is configurable via `iconWithLabel` (default `false`; when `true`, the leading icon renders inside the label+badge row so the description sits beneath both icon and label) and `iconVerticalAlign` (`'top'` \| `'center'` \| `'bottom'` — default `'center'`; only takes effect when `iconWithLabel={false}`). | | **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, customizable labels/icons, and an optional target marker for visualising goals (e.g. "90% target coverage"). | @@ -46,16 +46,17 @@ | **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, raised (subtle 3D inset highlight when paired with `flat`), customTheme, tooltip, disabled, effect / labelEffect / descriptionEffect, wrap (single-line ellipsis on `false`), optional strip-hide, and click handlers. | +| **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, raised (subtle 3D inset highlight when paired with `flat`), customTheme, tooltip, disabled, effect / labelEffect / descriptionEffect, wrap (single-line ellipsis on `false`), optional strip-hide, `padded` (`true` \| `false` \| `'horizontal'` \| `'vertical'` — default `true`), `paddingSize` (`TSizes` — defaults to `size`), 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. | | **ReqoreSpan** | Inline text component with theme support and text effects. | | **ReqoreSpinner** | Animated loading indicator with optional text label and multiple icon variants. | -| **ReqoreStatistic** | Metric display component showing values with optional prefixes, suffixes, trends, and labels. Optional `raised` adds a subtle 3D inset highlight when the surface is also `rounded` and `flat`. | +| **ReqoreStatistic** | Metric display component showing values with optional prefixes, suffixes, trends, and labels. Optional `raised` adds a subtle 3D inset highlight when the surface is also `rounded` and `flat`. Outer padding is configurable via `padded` (`true` \| `false` \| `'horizontal'` \| `'vertical'` — default `true`; only applies when the tile has a background) and `paddingSize` (`TSizes` — defaults to `size`). | | **ReqoreTable** | Advanced data table with sorting, filtering, column pinning, resizing, paging, and export capabilities. | | **ReqoreTabs** | Tab interface component supporting closeable tabs, vertical orientation, and uncontrolled/controlled states. | | **ReqoreTag** | Compact labeled component with optional icons, actions, colors, and badge styling. | +| **ReqoreTestimonial** | Quote-card surface for social proof, customer testimonials, and endorsements. Renders an optional decorative leading quote glyph, an optional star `rating`, the `quote` body (or `children`), an attribution footer (built on `ReqoreEntityRow`) with `avatar` (image) or `avatarIcon`, `author`, and `role`, plus badge support (TReqoreBadge) and right-side action buttons. Supports the standard prop set: intent (controls accent + border + tinted background), size, flat (border on `false`), rounded, fluid, fixed, transparent, raised (subtle 3D inset highlight when paired with `flat`), customTheme, tooltip, disabled, interactive (auto-detected from `onClick`), effect / quoteEffect / authorEffect / roleEffect, wrap (single-line ellipsis on `false`), `padded` (`true` \| `false` \| `'horizontal'` \| `'vertical'` — default `true`), and `paddingSize` (`TSizes` — defaults to `size`). | | **ReqoreTextarea** | Multi-line text input with optional auto-sizing, clear button, and template variable support. | | **ReqoreTier** | Pricing/feature tier card component with highlighted states, price display, and feature lists. | | **TimeAgo** | Relative time display component (e.g., "2 hours ago") that updates periodically. | diff --git a/src/components/Callout/index.tsx b/src/components/Callout/index.tsx index db53257b..34a9523c 100644 --- a/src/components/Callout/index.tsx +++ b/src/components/Callout/index.tsx @@ -1,7 +1,7 @@ import { rgba } from 'polished'; import { forwardRef, memo, useMemo } from 'react'; import styled, { css } from 'styled-components'; -import { PADDING_FROM_SIZE, RADIUS_FROM_SIZE, TEXT_FROM_SIZE } from '../../constants/sizes'; +import { PADDING_FROM_SIZE, RADIUS_FROM_SIZE, TEXT_FROM_SIZE, TSizes } from '../../constants/sizes'; import { IReqoreTheme } from '../../constants/theme'; import { changeDarkness, @@ -9,7 +9,7 @@ import { getMainBackgroundColor, getReadableColor, } from '../../helpers/colors'; -import { getOneLessSize } from '../../helpers/utils'; +import { getOneLessSize, TReqorePadded } from '../../helpers/utils'; import { useReqoreTheme } from '../../hooks/useTheme'; import { DisabledElement, RaisedElement } from '../../styles'; import { @@ -81,6 +81,23 @@ export interface IReqoreCalloutProps * when `flat={false}` because the border already provides surface definition. */ raised?: boolean; + /** + * Controls which axes receive the callout's outer padding. + * - `true` (default): padding on both axes + * - `false`: no padding (e.g. when nested inside another padded surface) + * - `'horizontal'`: only left/right padding + * - `'vertical'`: only top/bottom padding + * + * Note: when an accent strip is rendered, the side adjacent to the strip + * still reserves room for the strip even when padding is disabled on that + * axis — otherwise the content would collide with the strip. + */ + padded?: TReqorePadded; + /** + * Size of the callout's outer padding. Defaults to `size`. Use this to + * scale the padding independently from the callout's text scale. + */ + paddingSize?: TSizes; } interface IStyledCalloutProps extends Omit { @@ -88,6 +105,8 @@ interface IStyledCalloutProps extends Omit` @@ -97,14 +116,34 @@ const StyledCallout = styled(StyledEffect)` 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 }) => - accentPosition === 'left' - ? `${PADDING_FROM_SIZE[size] * 2.5}px ${PADDING_FROM_SIZE[size] * 3}px ${ - PADDING_FROM_SIZE[size] * 2.5 - }px ${PADDING_FROM_SIZE[size] * 3 + accentSize}px` - : `${PADDING_FROM_SIZE[size] * 3 + accentSize}px ${PADDING_FROM_SIZE[size] * 3}px ${ - PADDING_FROM_SIZE[size] * 3 - }px`}; + padding: ${({ $padded, $paddingSize, accentPosition = 'left', accentSize = 5 }) => { + if ($padded === false) { + // Even with no padding requested, the side that hosts the accent strip + // must reserve `accentSize` so the content does not collide with it. + return accentPosition === 'left' + ? `0 0 0 ${accentSize}px` + : `${accentSize}px 0 0 0`; + } + const v2_5 = PADDING_FROM_SIZE[$paddingSize] * 2.5; + const v3 = PADDING_FROM_SIZE[$paddingSize] * 3; + const h3 = PADDING_FROM_SIZE[$paddingSize] * 3; + const horizontalOnly = $padded === 'horizontal'; + const verticalOnly = $padded === 'vertical'; + if (accentPosition === 'left') { + // top, right, bottom, left (left side hosts the strip) + const top = horizontalOnly ? 0 : v2_5; + const right = verticalOnly ? 0 : h3; + const bottom = horizontalOnly ? 0 : v2_5; + const left = verticalOnly ? accentSize : h3 + accentSize; + return `${top}px ${right}px ${bottom}px ${left}px`; + } + // accentPosition === 'top' — top side hosts the strip + const top = horizontalOnly ? accentSize : v3 + accentSize; + const right = verticalOnly ? 0 : h3; + const bottom = horizontalOnly ? 0 : v3; + const left = verticalOnly ? 0 : h3; + return `${top}px ${right}px ${bottom}px ${left}px`; + }}; background-color: ${({ theme, $transparent }) => $transparent ? 'transparent' : changeDarkness(getMainBackgroundColor(theme), 0.03)}; border: ${({ theme, flat, intent }) => @@ -147,17 +186,18 @@ const StyledCallout = styled(StyledEffect)` ${({ disabled }) => disabled && DisabledElement} - ${({ interactive, theme, intent }) => + ${({ interactive, theme, intent, $transparent }) => interactive ? css` cursor: pointer; &:hover { transform: translateY(-1px); - background-color: ${changeLightness( - intent ? theme.intents[intent] : getMainBackgroundColor(theme), - -0.32 - )}; + background-color: ${intent + ? rgba(theme.intents[intent], $transparent ? 0.04 : 0.1) + : $transparent + ? rgba(changeLightness(getMainBackgroundColor(theme), 0.08), 0.08) + : changeLightness(getMainBackgroundColor(theme), -0.32)}; } ` : undefined} @@ -223,6 +263,8 @@ export const ReqoreCallout = memo( onClick, accentPosition = 'left', accentSize = 5, + padded = true, + paddingSize, ...rest }, ref @@ -263,6 +305,8 @@ export const ReqoreCallout = memo( size={size} accentPosition={accentPosition} accentSize={accentSize} + $padded={padded} + $paddingSize={paddingSize ?? size} className={`${className || ''} reqore-callout`} > {hasIcon && ( diff --git a/src/components/EntityRow/index.tsx b/src/components/EntityRow/index.tsx index 9918ed0d..3428caa3 100644 --- a/src/components/EntityRow/index.tsx +++ b/src/components/EntityRow/index.tsx @@ -4,7 +4,7 @@ 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 { getOneLessSize, resolvePadding, TReqorePadded } from '../../helpers/utils'; import { useReqoreTheme } from '../../hooks/useTheme'; import { DisabledElement, RaisedElement } from '../../styles'; import { @@ -65,6 +65,29 @@ export interface IReqoreEntityRowProps rounded?: boolean; /** Show the leading icon tile. Default `true` when icon/iconImage is provided. */ showIcon?: boolean; + /** + * Show the tinted/rounded background tile around the leading icon. + * - When the row is opaque (`transparent={false}`, the default), defaults + * to `true`. + * - When the row is `transparent={true}`, defaults to `false` so the bare + * icon does not sit on a tile that fights the transparent surface. + * - Pass an explicit boolean to override the default in either direction. + */ + iconHasBackground?: boolean; + /** + * Controls which axes receive the row's outer padding. + * - `true` (default): padding on both axes + * - `false`: no padding (e.g. when nested inside another padded surface) + * - `'horizontal'`: only left/right padding + * - `'vertical'`: only top/bottom padding + */ + padded?: TReqorePadded; + /** + * Size of the row's outer padding. Defaults to `size`. Use this to scale + * the padding independently from the row's text/icon scale (e.g. a `big` + * row with `paddingSize='small'` for a denser layout). + */ + paddingSize?: TSizes; /** * Subtle 3D "raised" effect — inset top highlight + inset bottom shadow. * Best paired with `flat={true}` (no border); the highlight is suppressed @@ -96,7 +119,10 @@ interface IStyledRowProps { rounded?: boolean; disabled?: boolean; $hasIcon?: boolean; + $iconHasBackground?: boolean; $raised?: boolean; + $padded: TReqorePadded; + $paddingSize: TSizes; } const ICON_TILE_SIZE_FROM_SIZE: Record = { @@ -116,11 +142,18 @@ const tintedBgFor = (theme: IReqoreTheme, intent?: TReqoreIntent) => const StyledRow = styled(StyledEffect)` display: grid; - grid-template-columns: ${({ $hasIcon, size }) => - $hasIcon ? `${ICON_TILE_SIZE_FROM_SIZE[size]}px 1fr auto` : '1fr auto'}; + grid-template-columns: ${({ $hasIcon, $iconHasBackground, size }) => + $hasIcon + ? `${$iconHasBackground ? `${ICON_TILE_SIZE_FROM_SIZE[size]}px` : 'auto'} 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; + padding: ${({ $padded, $paddingSize }) => + resolvePadding({ + padded: $padded, + paddingSize: $paddingSize, + verticalMultiplier: 2, + horizontalMultiplier: 3, + })}; border-radius: ${({ rounded, size }) => (rounded ? `${RADIUS_FROM_SIZE[size]}px` : '0')}; background-color: ${({ theme, $intent, $transparent }) => $transparent ? 'transparent' : tintedBgFor(theme, $intent)}; @@ -136,13 +169,15 @@ const StyledRow = styled(StyledEffect)` color: ${({ theme }) => getReadableColor(theme, undefined, undefined, true)}; transition: background-color 0.15s ease-out; - ${({ $clickable, theme, $intent }) => + ${({ $clickable, theme, $intent, $transparent }) => $clickable && css` cursor: pointer; &:hover { background-color: ${$intent - ? rgba(theme.intents[$intent], 0.1) + ? rgba(theme.intents[$intent], $transparent ? 0.04 : 0.1) + : $transparent + ? rgba(changeLightness(getMainBackgroundColor(theme), 0.08), 0.08) : rgba(changeLightness(getMainBackgroundColor(theme), 0.08), 1)}; } `} @@ -226,6 +261,9 @@ const ReqoreEntityRow = memo( intent, transparent = false, showIcon, + iconHasBackground, + padded = true, + paddingSize, size = 'normal', flat = true, fluid = true, @@ -251,6 +289,10 @@ const ReqoreEntityRow = memo( const hasIcon = (showIcon ?? (!!icon || !!iconImage)) && (!!icon || !!iconImage); const interactive = !!(rest.onClick || rest.onDoubleClick); const hasBadge = badge !== undefined && badge !== null; + // Default: tile follows opacity. Transparent rows hide the tile so the + // bare icon does not sit on a tinted square that fights transparency. + // Explicit prop overrides in either direction. + const resolvedIconHasBackground = iconHasBackground ?? !transparent; const resolvedIconColor: TReqoreEffectColor = useMemo(() => { if (iconColor) return iconColor; @@ -275,25 +317,38 @@ const ReqoreEntityRow = memo( $clickable={interactive} disabled={disabled} $hasIcon={hasIcon} + $iconHasBackground={resolvedIconHasBackground} + $padded={padded} + $paddingSize={paddingSize ?? size} effect={effect} className={`${className || ''} reqore-entity-row`} > - {hasIcon && ( - + {hasIcon && + (resolvedIconHasBackground ? ( + + + + ) : ( - - )} + ))} diff --git a/src/components/ErrorBoundary/index.tsx b/src/components/ErrorBoundary/index.tsx index 52f89c13..835330c7 100644 --- a/src/components/ErrorBoundary/index.tsx +++ b/src/components/ErrorBoundary/index.tsx @@ -48,8 +48,8 @@ export class ErrorBoundary extends Component { theme: IReqoreTheme; $transparent?: boolean; $raised?: boolean; + $padded: TReqorePadded; + $paddingSize: TSizes; } const StyledFeatureCard = styled(StyledEffect)` @@ -96,7 +111,13 @@ const StyledFeatureCard = styled(StyledEffect)` gap: ${({ size = 'normal' }) => PADDING_FROM_SIZE[size]}px; width: ${({ fluid, fixed }) => (fluid && !fixed ? '100%' : undefined)}; max-width: 100%; - padding: ${({ size = 'normal' }) => PADDING_FROM_SIZE[size] * 3}px; + padding: ${({ $padded, $paddingSize }) => + resolvePadding({ + padded: $padded, + paddingSize: $paddingSize, + verticalMultiplier: 3, + horizontalMultiplier: 3, + })}; background-color: ${({ theme, $transparent }) => $transparent ? 'transparent' : changeDarkness(getMainBackgroundColor(theme), 0.03)}; border: ${({ theme, intent, flat }) => @@ -233,6 +254,8 @@ export const ReqoreFeatureCard = memo( effect, interactive, onClick, + padded = true, + paddingSize, ...rest }, ref @@ -260,6 +283,8 @@ export const ReqoreFeatureCard = memo( rounded={rounded} $transparent={transparent} $raised={raised} + $padded={padded} + $paddingSize={paddingSize ?? size} effect={{ interactive: isInteractive, ...effect }} interactive={isInteractive} onClick={onClick} diff --git a/src/components/Notifications/notification.tsx b/src/components/Notifications/notification.tsx index b3ab0cfd..99f284bd 100644 --- a/src/components/Notifications/notification.tsx +++ b/src/components/Notifications/notification.tsx @@ -15,6 +15,7 @@ import { getReadableColor, } from '../../helpers/colors'; import { useReqoreTheme } from '../../hooks/useTheme'; +import { resolvePadding, TReqorePadded } from '../../helpers/utils'; import { IWithReqoreCustomTheme, IWithReqoreEffect, @@ -48,6 +49,18 @@ export interface IReqoreNotificationProps flat?: boolean; size?: TSizes; blur?: number; + /** + * Controls which axes receive the notification's outer padding. + * - `true` (default): padding on both axes + * - `false`: no padding + * - `'horizontal'`: only left/right padding + * - `'vertical'`: only top/bottom padding + */ + padded?: TReqorePadded; + /** + * Size of the notification's outer padding. Defaults to `size`. + */ + paddingSize?: TSizes; } export interface IReqoreNotificationStyle extends IWithReqoreOpaque { @@ -65,6 +78,8 @@ export interface IReqoreNotificationStyle extends IWithReqoreOpaque { margin?: 'top' | 'bottom' | 'both' | 'none'; backgroundBlur?: number; raised?: boolean; + $padded?: TReqorePadded; + $paddingSize?: TSizes; } const timeoutAnimation = keyframes` @@ -220,7 +235,13 @@ export const StyledNotificationContentWrapper = styled.div `${PADDING_FROM_SIZE[size]}px`}; + padding: ${({ size = 'normal', $padded = true, $paddingSize }: IReqoreNotificationStyle) => + resolvePadding({ + padded: $padded, + paddingSize: $paddingSize ?? size, + verticalMultiplier: 1, + horizontalMultiplier: 1, + })}; `; export const StyledNotificationTitle = styled.h4` @@ -275,6 +296,8 @@ const ReqoreNotification = forwardRef( size = 'normal', customTheme, inheritCustomTheme, + padded = true, + paddingSize, }, ref: any ) => { @@ -338,7 +361,12 @@ const ReqoreNotification = forwardRef( theme={theme} maxWidth='450px' > - + {type || intent || icon ? ( <> {intent === 'pending' || type === 'pending' ? ( diff --git a/src/components/Panel/index.tsx b/src/components/Panel/index.tsx index 9fdd7412..f143817c 100644 --- a/src/components/Panel/index.tsx +++ b/src/components/Panel/index.tsx @@ -64,7 +64,7 @@ import ReqoreDropdown, { IReqoreDropdownProps } from '../Dropdown'; import { IReqoreDropdownItem } from '../Dropdown/list'; import { IReqoreEffect, StyledEffect, TReqoreEffectColor } from '../Effect'; import { ReqoreErrorBoundary } from '../ErrorBoundary'; -import ReqoreIcon, { IReqoreIconProps, StyledIconWrapper } from '../Icon'; +import ReqoreIcon, { IReqoreIconProps } from '../Icon'; import { ReqoreSkeleton } from '../Skeleton'; import { ReqoreSpan } from '../Span'; import { ReqoreTooltipComponent } from '../TooltipComponent'; @@ -117,6 +117,22 @@ export interface IReqorePanelProps icon?: IReqoreIconName; iconProps?: IReqoreIconProps; + /** + * When `true`, the leading icon is rendered inside the label/badge row so + * the description appears beneath both the icon and the label. When `false` + * (default), the icon sits to the left of the label+description stack and + * its vertical alignment is controlled by `iconVerticalAlign`. + */ + iconWithLabel?: boolean; + /** + * Vertical alignment of the leading icon relative to the label+description + * stack. Only takes effect when `iconWithLabel={false}` (the default + * layout). Defaults to `'center'`. + * - `'top'`: icon aligns with the label line + * - `'center'`: icon centres against the whole label+description block + * - `'bottom'`: icon aligns with the description line + */ + iconVerticalAlign?: 'top' | 'center' | 'bottom'; label?: string | ReactElement; badge?: TReqoreBadge | TReqoreBadge[]; description?: string; @@ -193,10 +209,79 @@ export const StyledPanelTitleHeader = styled.div` overflow: hidden; `; -export const StyledPanelTitleHeaderContent = styled.div` - display: flex; - justify-content: flex-start; - align-items: center; +export const StyledPanelTitleHeaderContent = styled.div<{ + iconSize?: number; + hasIcon?: boolean; + size?: TSizes; + $hasOuterIcon?: boolean; + $iconVerticalAlign?: 'top' | 'center' | 'bottom'; + $hasDescription?: boolean; +}>` + // When the icon lives in the outer container (iconVerticalAlign mode), + // we use a 2-column × N-row CSS Grid where the LABEL ROW and DESCRIPTION + // ROW are direct grid items — not wrapped inside a span-2 stack. That + // makes each row's auto-sized height equal to the actual text row's + // line-box height, so 'align-self: center' on the icon (in column 1) + // visually centres the icon against the text row it shares a grid row + // with — no font-metric guesswork. + display: ${({ $hasOuterIcon }) => ($hasOuterIcon ? 'grid' : 'flex')}; + ${({ $hasOuterIcon, size, $iconVerticalAlign = 'center', $hasDescription }) => + $hasOuterIcon + ? css` + // 'auto' lets the column grow to the icon's actual rendered size — + // important when consumers pass a custom 'iconProps.size' that does + // not match the default size derived from 'panelSize'/'labelSize'. + // 'iconSize' is still passed via 'min-width' below to ensure a + // sensible minimum reservation when the row is otherwise empty. + grid-template-columns: auto minmax(0, 1fr); + grid-template-rows: ${$hasDescription ? 'auto auto' : 'auto'}; + column-gap: ${PADDING_FROM_SIZE[size || 'normal']}px; + row-gap: 3px; + align-items: start; + + & > .reqore-panel-title-icon { + ${$iconVerticalAlign === 'top' + ? css` + grid-row: 1; + ` + : $iconVerticalAlign === 'bottom' && $hasDescription + ? css` + grid-row: 2; + ` + : css` + grid-row: 1 / ${$hasDescription ? 3 : 2}; + `} + grid-column: 1; + align-self: center; + justify-self: start; + // Suppress 'vertical-align: super' that ReqoreIcon applies to its + // SVG for inline-text contexts. In our grid layout the wrapper is + // already vertically centred against the text row's line-box; + // 'display: block' takes the SVG out of inline flow entirely so + // baseline/super offsets cannot push it off-centre. The wrapper's + // own 'align-items: center' then centres the SVG within itself. + & svg { + display: block; + vertical-align: middle; + } + } + + & > .reqore-panel-title-label-row { + grid-column: 2; + grid-row: 1; + min-width: 0; + } + + & > .reqore-panel-title-description { + grid-column: 2; + grid-row: 2; + min-width: 0; + } + ` + : css` + justify-content: flex-start; + align-items: center; + `} flex: 0 1 auto; overflow: hidden; min-width: ${({ iconSize, hasIcon }) => { @@ -220,6 +305,7 @@ export const StyledPanelTitleHeaderLabelAndDescription = styled.div` export const StyledPanelTitleHeaderLabelAndBadge = styled.div` display: inline-flex; + align-items: center; max-width: 100%; `; @@ -270,7 +356,7 @@ export const StyledPanel: TPanelStyle = styled(StyledEffect)` cursor: pointer; &:hover { - ${StyledPanelTitle} ${StyledPanelTitleHeaderContent} > ${StyledIconWrapper} { + ${StyledPanelTitle} ${StyledPanelTitleHeaderContent} .reqore-panel-title-icon { transform: scale(${ACTIVE_ICON_SCALE}); } @@ -344,7 +430,7 @@ export const StyledPanelTitle = styled.div` flex: 0 0 auto; gap: ${GAP_FROM_SIZE.normal}px; - ${StyledPanelTitleHeaderContent} > ${StyledIconWrapper} { + ${StyledPanelTitleHeaderContent} .reqore-panel-title-icon { transform: scale(${INACTIVE_ICON_SCALE}); } @@ -353,7 +439,7 @@ export const StyledPanelTitle = styled.div` css` cursor: pointer; &:hover { - ${StyledPanelTitleHeaderContent} > ${StyledIconWrapper} { + ${StyledPanelTitleHeaderContent} .reqore-panel-title-icon { transform: scale(${ACTIVE_ICON_SCALE}); } @@ -487,6 +573,8 @@ export const ReqorePanel = forwardRef( inheritCustomTheme, icon, iconImage, + iconWithLabel = false, + iconVerticalAlign = 'center', intent, className, flat, @@ -1028,22 +1116,41 @@ export const ReqorePanel = forwardRef( responsive /> ) : icon || iconImage || label || badge ? ( - - {icon || iconImage ? ( + (() => { + const hasPanelIcon = !!icon || !!iconImage || loading; + + // Layout decision: + // - iconWithLabel=true → render the icon INSIDE the + // label-and-badge row (no outer icon column). The + // description sits flush-left under the icon+label. + // - When there is NO description, fall back to the + // inline layout regardless of iconVerticalAlign — the + // outer grid only earns its keep when there is a + // description row to anchor against, and the inline + // layout aligns icon-to-text glyphs more naturally + // via the heading's own line-height. + // - Otherwise → outer two-column grid where the icon + // sits in a reserved column. iconVerticalAlign places + // the icon in row 1 (label), row 2 (description), or + // spans both rows (center). The description always + // indents past the icon column. + const inlineWithLabel = + hasPanelIcon && (iconWithLabel || !description); + const useOuterGrid = hasPanelIcon && !inlineWithLabel; + + // Outer grid → grid column-gap handles icon→label + // spacing, so the icon itself takes no margin. + // Inline → keep 'margin=right' so ReqoreIcon adds its + // standard side-spacer between icon and label. + const iconMargin: 'right' | undefined = useOuterGrid ? undefined : 'right'; + + const panelIcon = hasPanelIcon ? ( ( {...iconProps} animation={loading ? 'spin' : iconProps?.animation} icon={ - loading ? `Loader${loadingIconType || ''}Line` : icon || iconProps?.icon + loading + ? `Loader${loadingIconType || ''}Line` + : icon || iconProps?.icon } + className={`reqore-panel-title-icon ${iconProps?.className || ''}`.trim()} /> - ) : null} - - + ) : null; + + const labelRow = ( + + {inlineWithLabel && panelIcon} {typeof label === 'string' ? ( ( noWrap: true, ...labelEffect, }} - style={{ display: 'inline-flex', alignItems: 'center', minWidth: 0 }} + style={{ + display: 'inline-flex', + alignItems: 'center', + minWidth: 0, + }} label={label} onSubmit={onLabelEdit} tooltip={showLabelTooltip ? customLabelTooltip || label : undefined} @@ -1083,17 +1199,47 @@ export const ReqorePanel = forwardRef( /> ) : null} - {description && ( - - {description} - - )} - - + ); + + const descriptionRow = description ? ( + + {description} + + ) : null; + + return ( + + {useOuterGrid ? ( + <> + {panelIcon} + {labelRow} + {descriptionRow} + + ) : ( + + {labelRow} + {descriptionRow} + + )} + + ); + })() ) : null} {breadcrumbs && (badge || badge === 0) ? ( @@ -98,8 +113,13 @@ 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; + padding: ${({ $padded, $paddingSize }) => + resolvePadding({ + padded: $padded, + paddingSize: $paddingSize, + verticalMultiplier: 2, + horizontalMultiplier: 3, + })}; border-radius: ${({ rounded, size }) => (rounded ? `${RADIUS_FROM_SIZE[size]}px` : '0')}; background-color: ${({ theme, $intent, $transparent }) => $transparent ? 'transparent' : tintedBgFor(theme, $intent)}; @@ -115,13 +135,15 @@ const StyledRow = styled(StyledEffect)` color: ${({ theme }) => getReadableColor(theme, undefined, undefined, true)}; transition: background-color 0.15s ease-out; - ${({ $clickable, theme, $intent }) => + ${({ $clickable, theme, $intent, $transparent }) => $clickable && css` cursor: pointer; &:hover { background-color: ${$intent - ? rgba(theme.intents[$intent], 0.1) + ? rgba(theme.intents[$intent], $transparent ? 0.04 : 0.1) + : $transparent + ? rgba(changeLightness(getMainBackgroundColor(theme), 0.08), 0.08) : rgba(changeLightness(getMainBackgroundColor(theme), 0.08), 1)}; } `} @@ -203,6 +225,8 @@ const ReqoreSeverityRow = memo( labelEffect, descriptionEffect, wrap = true, + padded = true, + paddingSize, className, ...rest }, @@ -230,6 +254,8 @@ const ReqoreSeverityRow = memo( rounded={rounded} $raised={raised} $clickable={interactive} + $padded={padded} + $paddingSize={paddingSize ?? size} disabled={disabled} effect={effect} className={`${className || ''} reqore-severity-row`} diff --git a/src/components/Statistic/index.tsx b/src/components/Statistic/index.tsx index 30eb922d..9caf765b 100644 --- a/src/components/Statistic/index.tsx +++ b/src/components/Statistic/index.tsx @@ -1,7 +1,7 @@ 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 { RADIUS_FROM_SIZE, TSizes } from '../../constants/sizes'; import { IReqoreTheme, TReqoreIntent } from '../../constants/theme'; import { changeDarkness, @@ -9,7 +9,13 @@ import { getMainBackgroundColor, getReadableColor, } from '../../helpers/colors'; -import { alignToFlexAlign, getOneHigherSize, getOneLessSize } from '../../helpers/utils'; +import { + alignToFlexAlign, + getOneHigherSize, + getOneLessSize, + resolvePadding, + TReqorePadded, +} from '../../helpers/utils'; import { useReqoreTheme } from '../../hooks/useTheme'; import { DisabledElement, InactiveIconScale, RaisedElement, ScaleIconOnHover } from '../../styles'; import { @@ -87,6 +93,21 @@ export interface IReqoreStatisticProps * reads as a tactile card; the highlight is suppressed when `flat={false}`. */ raised?: boolean; + /** + * Controls which axes receive the tile's outer padding (only applies when + * the tile has a background — i.e. when `effect`/`rounded`/`flat`/ + * `transparent`/`opacity` is set or `raised` is true). + * - `true` (default): padding on both axes + * - `false`: no padding + * - `'horizontal'`: only left/right padding + * - `'vertical'`: only top/bottom padding + */ + padded?: TReqorePadded; + /** + * Size of the tile's outer padding. Defaults to `size`. Use this to scale + * the padding independently from the tile's text/icon scale. + */ + paddingSize?: TSizes; } interface IStyledStatisticWrapper { @@ -102,6 +123,8 @@ interface IStyledStatisticWrapper { intent?: string; opacity?: number; $raised?: boolean; + $padded: TReqorePadded; + $paddingSize: TSizes; } const TREND_ICONS: Record = { @@ -121,7 +144,17 @@ const StyledStatisticWrapper = styled(StyledEffect)` justify-content: ${({ $align }) => $align}; width: ${({ $fluid }) => ($fluid ? '100%' : undefined)}; - ${({ $hasBackground, theme, size, rounded, flat, intent, opacity = 1 }) => + ${({ + $hasBackground, + theme, + size, + rounded, + flat, + intent, + opacity = 1, + $padded, + $paddingSize, + }) => $hasBackground && css` background-color: ${rgba(changeDarkness(getMainBackgroundColor(theme), 0.03), opacity)}; @@ -133,7 +166,12 @@ const StyledStatisticWrapper = styled(StyledEffect)` 0.08 )}`}; color: ${getReadableColor(theme, undefined, undefined, true)}; - padding: ${PADDING_FROM_SIZE[size] * 3}px ${PADDING_FROM_SIZE[size] * 5}px; + padding: ${resolvePadding({ + padded: $padded, + paddingSize: $paddingSize, + verticalMultiplier: 3, + horizontalMultiplier: 5, + })}; `} ${({ $raised, $hasBackground, flat }) => @@ -195,6 +233,8 @@ const ReqoreStatistic = memo( transparent, opacity, raised, + padded = true, + paddingSize, className, ...rest }, @@ -256,6 +296,8 @@ const ReqoreStatistic = memo( intent={intent} opacity={transparent ? 0 : opacity} $raised={raised} + $padded={padded} + $paddingSize={paddingSize ?? size} effect={transformedEffect} className={`${className || ''} reqore-statistic`} > diff --git a/src/components/Testimonial/index.tsx b/src/components/Testimonial/index.tsx new file mode 100644 index 00000000..1fec8e9d --- /dev/null +++ b/src/components/Testimonial/index.tsx @@ -0,0 +1,384 @@ +import { rgba } from 'polished'; +import { forwardRef, memo, useMemo } from 'react'; +import styled, { css } from 'styled-components'; +import { + PADDING_FROM_SIZE, + RADIUS_FROM_SIZE, + TEXT_FROM_SIZE, + TSizes, +} from '../../constants/sizes'; +import { IReqoreTheme, TReqoreIntent } from '../../constants/theme'; +import { + changeDarkness, + changeLightness, + getMainBackgroundColor, + getReadableColor, +} from '../../helpers/colors'; +import { getOneLessSize, resolvePadding, TReqorePadded } from '../../helpers/utils'; +import { useReqoreTheme } from '../../hooks/useTheme'; +import { DisabledElement, RaisedElement } from '../../styles'; +import { + IReqoreDisabled, + IReqoreIntent, + IWithReqoreCustomTheme, + IWithReqoreEffect, + IWithReqoreFixed, + IWithReqoreFlat, + IWithReqoreFluid, + IWithReqoreSize, + IWithReqoreTooltip, +} from '../../types/global'; +import { IReqoreIconName } from '../../types/icons'; +import ReqoreButton, { IReqoreButtonProps, TReqoreBadge } from '../Button'; +import ReqoreControlGroup from '../ControlGroup'; +import { IReqoreEffect, StyledEffect, TReqoreEffectColor } from '../Effect'; +import ReqoreEntityRow from '../EntityRow'; +import ReqoreIcon, { IReqoreIconProps } from '../Icon'; +import { ReqoreP } from '../Paragraph'; +import ReqoreRating from '../Rating'; +import { ReqoreTooltipComponent } from '../TooltipComponent'; + +export interface IReqoreTestimonialAction extends Omit { + /** Visible button label. */ + label?: string; +} + +export interface IReqoreTestimonialProps + extends Omit, 'title' | 'role'>, + IReqoreDisabled, + IReqoreIntent, + IWithReqoreCustomTheme, + IWithReqoreEffect, + IWithReqoreFixed, + IWithReqoreFlat, + IWithReqoreFluid, + IWithReqoreSize, + IWithReqoreTooltip { + /** The testimonial body — the quote / endorsement copy. Falls back to `children`. */ + quote?: React.ReactNode; + /** Effect applied to the quote text. */ + quoteEffect?: IReqoreEffect; + /** Author name shown beneath the quote. */ + author?: React.ReactNode; + /** Effect applied to the author label. */ + authorEffect?: IReqoreEffect; + /** Author role / company line beneath the author name. */ + role?: React.ReactNode; + /** Effect applied to the role label. */ + roleEffect?: IReqoreEffect; + /** Image URL for the author avatar. Takes precedence over `avatarIcon`. */ + avatar?: string; + /** Icon used as the author avatar when no `avatar` image is provided. */ + avatarIcon?: IReqoreIconName; + /** Custom color for the avatar icon (defaults to intent or readable color). */ + avatarColor?: TReqoreEffectColor; + /** Additional props passed to the avatar ReqoreIcon. */ + avatarIconProps?: Partial; + /** Numeric rating shown above the quote (0..maxRating, half-steps supported). */ + rating?: number; + /** Maximum rating value. Default `5`. */ + maxRating?: number; + /** Badge(s) shown next to the author name, identical to other Reqore components. */ + badge?: TReqoreBadge | TReqoreBadge[]; + /** Action button(s) rendered below the quote. */ + actions?: IReqoreTestimonialAction[]; + /** Show the decorative leading quote glyph above the quote. Default `true`. */ + showQuoteIcon?: boolean; + /** Round the corners. Default `true`. */ + rounded?: boolean; + /** Hide the tinted surface background. */ + transparent?: boolean; + /** + * Subtle 3D "raised" effect — inset top highlight + inset bottom shadow. + * Best paired with `flat={true}` (no border); the highlight is suppressed + * when `flat={false}` because the border already provides surface definition. + */ + raised?: boolean; + /** Marks the testimonial as clickable; auto-detected from `onClick`. */ + interactive?: boolean; + /** + * Whether the quote wraps when it overflows. + * - `true` (default): wrap to multiple lines (normal blockquote behaviour) + * - `false`: single line with ellipsis (compact card use) + */ + wrap?: boolean; + /** + * Controls which axes receive the card's outer padding. + * - `true` (default): padding on both axes + * - `false`: no padding (e.g. when nested inside another padded surface) + * - `'horizontal'`: only left/right padding + * - `'vertical'`: only top/bottom padding + */ + padded?: TReqorePadded; + /** + * Size of the card's outer padding. Defaults to `size`. Use this to scale + * the padding independently from the card's text scale. + */ + paddingSize?: TSizes; +} + +interface IStyledTestimonialProps { + theme: IReqoreTheme; + $intent?: TReqoreIntent; + $transparent?: boolean; + size: TSizes; + $fluid?: boolean; + $fixed?: boolean; + flat?: boolean; + rounded?: boolean; + disabled?: boolean; + $raised?: boolean; + $interactive?: boolean; + $padded: TReqorePadded; + $paddingSize: TSizes; +} + +const tintedBgFor = (theme: IReqoreTheme, intent?: TReqoreIntent) => + intent + ? rgba(theme.intents[intent], 0.06) + : changeDarkness(getMainBackgroundColor(theme), 0.03); + +const StyledTestimonial = styled(StyledEffect)` + position: relative; + display: flex; + flex-flow: column; + gap: ${({ size }) => PADDING_FROM_SIZE[size] * 1.5}px; + width: ${({ $fluid, $fixed }) => ($fluid && !$fixed ? '100%' : undefined)}; + max-width: 100%; + padding: ${({ $padded, $paddingSize }) => + resolvePadding({ + padded: $padded, + paddingSize: $paddingSize, + verticalMultiplier: 3, + horizontalMultiplier: 3, + })}; + 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 + )}`}; + border-radius: ${({ rounded, size }) => (rounded ? `${RADIUS_FROM_SIZE[size]}px` : '0')}; + color: ${({ theme }) => getReadableColor(theme, undefined, undefined, true)}; + flex: ${({ $fluid }) => ($fluid ? '1 auto' : '0 0 auto')}; + margin: 0; + transition: + background-color 0.16s ease, + border-color 0.16s ease, + transform 0.16s ease; + + ${({ $raised, flat }) => $raised && flat !== false && RaisedElement} + + ${({ disabled }) => disabled && DisabledElement} + + ${({ $interactive, theme, $intent, $transparent }) => + $interactive && + css` + cursor: pointer; + + &:hover { + transform: translateY(-1px); + background-color: ${$intent + ? rgba(theme.intents[$intent], $transparent ? 0.04 : 0.1) + : $transparent + ? rgba(changeLightness(getMainBackgroundColor(theme), 0.08), 0.08) + : changeLightness(getMainBackgroundColor(theme), 0.04)}; + } + `} +`; + +const StyledQuoteIconWrapper = styled.div<{ + theme: IReqoreTheme; + $intent?: TReqoreIntent; +}>` + display: flex; + color: ${({ theme, $intent }) => + $intent ? theme.intents[$intent] : changeLightness(getMainBackgroundColor(theme), 0.22)}; + opacity: 0.7; +`; + +const StyledQuote = styled.div<{ $wrap: boolean; size: TSizes; theme: IReqoreTheme }>` + font-size: ${({ size }) => TEXT_FROM_SIZE[size] * 1.1}px; + line-height: 1.5; + font-weight: 500; + font-style: italic; + min-width: 0; + + ${({ $wrap }) => + !$wrap && + css` + overflow: hidden; + + & > * { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: block; + max-width: 100%; + } + `} +`; + +const ReqoreTestimonial = memo( + forwardRef( + ( + { + children, + quote, + quoteEffect, + author, + authorEffect, + role, + roleEffect, + avatar, + avatarIcon, + avatarColor, + avatarIconProps, + rating, + maxRating = 5, + badge, + actions, + intent, + transparent = false, + showQuoteIcon = true, + size = 'normal', + flat = true, + fluid = true, + fixed, + rounded = true, + raised, + customTheme, + inheritCustomTheme, + disabled, + tooltip, + effect, + interactive, + onClick, + wrap = true, + padded = true, + paddingSize, + className, + ...rest + }, + ref + ) => { + const theme = useReqoreTheme('main', customTheme, intent, undefined, inheritCustomTheme); + const secondarySize = useMemo(() => getOneLessSize(size), [size]); + const isInteractive = interactive || !!onClick; + const hasBadge = badge !== undefined && badge !== null; + const hasAvatar = !!avatar || !!avatarIcon; + const hasAttribution = !!author || !!role || hasAvatar || hasBadge; + const quoteContent = quote ?? children; + + const resolvedAvatarColor: TReqoreEffectColor = useMemo(() => { + if (avatarColor) return avatarColor; + if (intent) return theme.intents[intent] as TReqoreEffectColor; + return getReadableColor(theme, undefined, undefined, true) as TReqoreEffectColor; + }, [avatarColor, intent, theme]); + + return ( + + {showQuoteIcon && ( + + + + )} + {rating !== undefined && ( + + )} + {quoteContent && ( + + + {quoteContent} + + + )} + {hasAttribution && ( + + )} + {actions && actions.length > 0 && ( + + {actions.map((action, idx) => ( + + {action.label} + + ))} + + )} + + ); + } + ) +); + +export default ReqoreTestimonial; diff --git a/src/helpers/utils.ts b/src/helpers/utils.ts index 60e2e67d..1e179c04 100644 --- a/src/helpers/utils.ts +++ b/src/helpers/utils.ts @@ -9,7 +9,7 @@ import { isUndefined, } from 'lodash'; import { IReqorePanelAction, IReqorePanelSubAction } from '../components/Panel'; -import { NUMBER_TO_SIZE, SIZES, SIZE_TO_NUMBER, TSizes } from '../constants/sizes'; +import { NUMBER_TO_SIZE, PADDING_FROM_SIZE, SIZES, SIZE_TO_NUMBER, TSizes } from '../constants/sizes'; import { TReqoreTooltipProp } from '../types/global'; export const sleep = async (ms: number) => await new Promise((r) => setTimeout(r, ms)); @@ -108,6 +108,40 @@ export const getOneLessSize = (size: TSizes = 'normal'): TSizes => { return NUMBER_TO_SIZE[oneLessSizeNumber]; }; +export type TReqorePadded = boolean | 'horizontal' | 'vertical'; + +/** + * Builds a CSS `padding` shorthand value for surface components that support + * the `padded` + `paddingSize` prop pair. + * + * - `padded={false}` → no padding + * - `padded='horizontal'` → only left/right padding + * - `padded='vertical'` → only top/bottom padding + * - `padded={true}` (default) → padding on both axes + * + * The base padding for each axis is `PADDING_FROM_SIZE[paddingSize] * + * `. Pass per-component multipliers (e.g. EntityRow uses `v=2, + * h=3`; Statistic uses `v=3, h=5`). + */ +export const resolvePadding = ({ + padded, + paddingSize, + verticalMultiplier, + horizontalMultiplier, +}: { + padded: TReqorePadded; + paddingSize: TSizes; + verticalMultiplier: number; + horizontalMultiplier: number; +}): string => { + if (padded === false) return '0'; + const v = PADDING_FROM_SIZE[paddingSize] * verticalMultiplier; + const h = PADDING_FROM_SIZE[paddingSize] * horizontalMultiplier; + if (padded === 'horizontal') return `0 ${h}px`; + if (padded === 'vertical') return `${v}px 0`; + return `${v}px ${h}px`; +}; + export const getOneHigherSize = (size: TSizes): TSizes => { // Get the initial sizes number const initialSizeNumber: number = SIZE_TO_NUMBER[size]; diff --git a/src/index.tsx b/src/index.tsx index 3b996dd5..42896b68 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -76,6 +76,7 @@ export { default as ReqoreTabsListItem } from './components/Tabs/item'; export { default as ReqoreTabsList } from './components/Tabs/list'; export { default as ReqoreTag } from './components/Tag'; export { default as ReqoreTagGroup } from './components/Tag/group'; +export { default as ReqoreTestimonial } from './components/Testimonial'; export { default as ReqoreTextarea } from './components/Textarea'; export { ReqoreTier } from './components/Tier'; export { TimeAgo as ReqoreTimeAgo } from './components/TimeAgo'; diff --git a/src/stories/Accordion/Accordion.stories.tsx b/src/stories/Accordion/Accordion.stories.tsx index db384618..e179f174 100644 --- a/src/stories/Accordion/Accordion.stories.tsx +++ b/src/stories/Accordion/Accordion.stories.tsx @@ -1,6 +1,5 @@ import { StoryObj } from '@storybook/react'; -import { ReqoreAccordion } from '../../components/Accordion'; -import { IReqoreAccordionItem } from '../../components/Accordion'; +import { IReqoreAccordionItem, ReqoreAccordion } from '../../components/Accordion'; import { ReqoreButton, ReqoreControlGroup } from '../../index'; import { StoryMeta } from '../utils'; @@ -24,17 +23,17 @@ type Story = StoryObj; const basicItems: IReqoreAccordionItem[] = [ { - title: 'What is ReQore?', + label: 'What is ReQore?', content: 'ReQore is a highly theme-able and modular UI library for React, ' + 'designed for the Qorus platform.', }, { - title: 'How do I install it?', + label: 'How do I install it?', content: 'You can install ReQore via npm or yarn: yarn add @qoretechnologies/reqore', }, { - title: 'Is it open source?', + label: 'Is it open source?', content: 'Yes! ReQore is open source and available on GitHub.', }, ]; @@ -47,21 +46,13 @@ export const Basic: Story = { export const WithDefaultOpen: Story = { args: { - items: [ - { ...basicItems[0], isOpen: true }, - basicItems[1], - basicItems[2], - ], + items: [{ ...basicItems[0], isOpen: true }, basicItems[1], basicItems[2]], }, }; export const SingleExpand: Story = { args: { - items: [ - { ...basicItems[0], isOpen: true }, - basicItems[1], - basicItems[2], - ], + items: [{ ...basicItems[0], isOpen: true }, basicItems[1], basicItems[2]], allowMultiple: false, }, }; @@ -70,18 +61,18 @@ export const WithIcons: Story = { args: { items: [ { - title: 'Getting Started', + label: 'Getting Started', content: 'Install the package and wrap your app with ReqoreUIProvider.', icon: 'RocketLine', isOpen: true, }, { - title: 'Theming', + label: 'Theming', content: 'Customize colors, fonts, and intents through the theme system.', icon: 'PaletteLine', }, { - title: 'Components', + label: 'Components', content: 'Over 40 components available: buttons, tables, modals, and more.', icon: 'LayoutGridLine', }, @@ -93,14 +84,14 @@ export const WithBadges: Story = { args: { items: [ { - title: 'Inbox', + label: 'Inbox', content: 'Your inbox messages appear here.', icon: 'InboxLine', badge: 12, isOpen: true, }, { - title: 'Notifications', + label: 'Notifications', content: 'System notifications and alerts.', icon: 'Notification2Line', badge: [ @@ -109,7 +100,7 @@ export const WithBadges: Story = { ], }, { - title: 'Archive', + label: 'Archive', content: 'Archived items are stored here.', icon: 'ArchiveLine', badge: 0, @@ -124,26 +115,26 @@ export const WithIntents: Story = { {...args} items={[ { - title: 'Info section', + label: 'Info section', content: 'This section has info intent.', icon: 'InformationLine', intent: 'info', isOpen: true, }, { - title: 'Success section', + label: 'Success section', content: 'This section has success intent.', icon: 'CheckLine', intent: 'success', }, { - title: 'Warning section', + label: 'Warning section', content: 'This section has warning intent.', icon: 'AlertLine', intent: 'warning', }, { - title: 'Danger section', + label: 'Danger section', content: 'This section has danger intent.', icon: 'ErrorWarningLine', intent: 'danger', @@ -174,13 +165,13 @@ export const Sizes: Story = { size={size} items={[ { - title: `${size} accordion item`, + label: `${size} accordion item`, content: `This is content for the ${size} size.`, icon: 'InformationLine', isOpen: true, }, { - title: 'Another item', + label: 'Another item', content: 'More content here.', }, ]} @@ -236,7 +227,7 @@ export const CustomContent: Story = { {...args} items={[ { - title: 'With action buttons', + label: 'With action buttons', icon: 'Settings3Line', isOpen: true, content: ( @@ -252,7 +243,7 @@ export const CustomContent: Story = { ), }, { - title: 'With a list', + label: 'With a list', icon: 'ListCheck2', content: (
    @@ -274,21 +265,21 @@ export const FAQExample: Story = { fluid items={[ { - title: 'How do I reset my password?', + label: 'How do I reset my password?', icon: 'LockLine', content: 'Go to the login page and click "Forgot password". ' + 'You will receive an email with a reset link.', }, { - title: 'Can I change my subscription plan?', + label: 'Can I change my subscription plan?', icon: 'ExchangeDollarLine', content: 'Yes, you can upgrade or downgrade your plan at any time ' + 'from the billing settings page.', }, { - title: 'How do I contact support?', + label: 'How do I contact support?', icon: 'CustomerService2Line', content: 'You can reach our support team via email at support@example.com ' + @@ -296,7 +287,7 @@ export const FAQExample: Story = { isOpen: true, }, { - title: 'Is there a free trial?', + label: 'Is there a free trial?', icon: 'GiftLine', content: 'Yes! We offer a 14-day free trial with full access to all features. ' + @@ -315,7 +306,7 @@ export const SettingsExample: Story = { allowMultiple={false} items={[ { - title: 'General', + label: 'General', icon: 'Settings3Line', isOpen: true, content: ( @@ -328,7 +319,7 @@ export const SettingsExample: Story = { ), }, { - title: 'Security', + label: 'Security', icon: 'ShieldLine', badge: { label: 'Action needed', intent: 'warning', icon: 'AlertLine' }, content: ( @@ -341,12 +332,12 @@ export const SettingsExample: Story = { ), }, { - title: 'Notifications', + label: 'Notifications', icon: 'Notification2Line', content: 'Configure email, push, and in-app notification preferences.', }, { - title: 'Danger Zone', + label: 'Danger Zone', icon: 'ErrorWarningLine', intent: 'danger', content: ( diff --git a/src/stories/Button/Button.stories.tsx b/src/stories/Button/Button.stories.tsx index 406b945b..7fab8e33 100644 --- a/src/stories/Button/Button.stories.tsx +++ b/src/stories/Button/Button.stories.tsx @@ -493,3 +493,52 @@ export const Raised: Story = { raised: true, }, }; + +export const MinimalRaised: Story = { + render: () => ( +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + ), +}; diff --git a/src/stories/Callout/Callout.stories.tsx b/src/stories/Callout/Callout.stories.tsx index d2716d88..d723d733 100644 --- a/src/stories/Callout/Callout.stories.tsx +++ b/src/stories/Callout/Callout.stories.tsx @@ -1,6 +1,7 @@ import { StoryFn, StoryObj } from '@storybook/react'; import { IReqoreCalloutProps, ReqoreCallout } from '../../components/Callout'; import ReqoreControlGroup from '../../components/ControlGroup'; +import { TSizes } from '../../constants/sizes'; import { DEFAULT_INTENTS } from '../../constants/theme'; import { StoryMeta } from '../utils'; import { FlatArg, IntentArg, SizeArg, argManager } from '../utils/args'; @@ -305,3 +306,60 @@ export const Raised: Story = { raised: true, }, }; + +const CALLOUT_SIZES: TSizes[] = ['tiny', 'small', 'normal', 'big', 'huge']; + +const renderCalloutMatrix = (variantArgs: Partial) => + CALLOUT_SIZES.map((size) => ( + + )); + +export const Unpadded: Story = { + render: () => ( + + {renderCalloutMatrix({ padded: false })} + + ), +}; + +export const PaddedHorizontalOnly: Story = { + render: () => ( + + {renderCalloutMatrix({ padded: 'horizontal' })} + + ), +}; + +export const PaddedVerticalOnly: Story = { + render: () => ( + + {renderCalloutMatrix({ padded: 'vertical' })} + + ), +}; + +export const CustomPaddingSize: Story = { + render: () => ( + + {CALLOUT_SIZES.map((size) => ( + + ))} + + ), +}; diff --git a/src/stories/EntityRow/EntityRow.stories.tsx b/src/stories/EntityRow/EntityRow.stories.tsx index c9dad2d7..49ccd28b 100644 --- a/src/stories/EntityRow/EntityRow.stories.tsx +++ b/src/stories/EntityRow/EntityRow.stories.tsx @@ -1,6 +1,7 @@ import { StoryObj } from '@storybook/react'; import ReqoreControlGroup from '../../components/ControlGroup'; -import ReqoreEntityRow from '../../components/EntityRow'; +import ReqoreEntityRow, { IReqoreEntityRowProps } from '../../components/EntityRow'; +import { TSizes } from '../../constants/sizes'; import { StoryMeta } from '../utils'; const meta = { @@ -205,10 +206,23 @@ export const NoWrap: Story = { export const Transparent: Story = { args: { label: 'Transparent entity row', - description: 'Even with intent set, the background stays transparent', + description: + 'Even with intent set, the background stays transparent — the icon tile is hidden by default so the bare icon does not sit on a tinted square that fights transparency.', + icon: 'SettingsLine', + intent: 'info', + transparent: true, + actions: [{ label: 'Open' }], + }, +}; + +export const TransparentWithIconTile: Story = { + args: { + label: 'Transparent with explicit tile', + description: 'Pass `iconHasBackground` to force the tile back even on a transparent row.', icon: 'SettingsLine', intent: 'info', transparent: true, + iconHasBackground: true, actions: [{ label: 'Open' }], }, }; @@ -224,3 +238,67 @@ export const Raised: Story = { actions: [{ label: 'Run', icon: 'PlayLine' }], }, }; + +const ENTITY_ROW_SIZES: TSizes[] = ['tiny', 'small', 'normal', 'big', 'huge']; + +const renderEntityRowMatrix = (variantArgs: Partial) => + ENTITY_ROW_SIZES.map((size) => ( + + )); + +export const IconWithoutBackground: Story = { + render: () => ( + + {renderEntityRowMatrix({ iconHasBackground: false })} + + ), +}; + +export const Unpadded: Story = { + render: () => ( + + {renderEntityRowMatrix({ padded: false })} + + ), +}; + +export const PaddedHorizontalOnly: Story = { + render: () => ( + + {renderEntityRowMatrix({ padded: 'horizontal' })} + + ), +}; + +export const PaddedVerticalOnly: Story = { + render: () => ( + + {renderEntityRowMatrix({ padded: 'vertical' })} + + ), +}; + +export const CustomPaddingSize: Story = { + render: () => ( + + {ENTITY_ROW_SIZES.map((size) => ( + + ))} + + ), +}; diff --git a/src/stories/FeatureCard/FeatureCard.stories.tsx b/src/stories/FeatureCard/FeatureCard.stories.tsx index b0a1c11e..38bc3a64 100644 --- a/src/stories/FeatureCard/FeatureCard.stories.tsx +++ b/src/stories/FeatureCard/FeatureCard.stories.tsx @@ -1,6 +1,7 @@ import { StoryFn, StoryObj } from '@storybook/react'; import ReqoreControlGroup from '../../components/ControlGroup'; import { IReqoreFeatureCardProps, ReqoreFeatureCard } from '../../components/FeatureCard'; +import { TSizes } from '../../constants/sizes'; import { DEFAULT_INTENTS } from '../../constants/theme'; import { StoryMeta } from '../utils'; import { FlatArg, IntentArg, SizeArg, argManager } from '../utils/args'; @@ -277,3 +278,58 @@ export const Raised: Story = { raised: true, }, }; + +const FEATURE_CARD_SIZES: TSizes[] = ['tiny', 'small', 'normal', 'big', 'huge']; + +const renderFeatureCardMatrix = (variantArgs: Partial) => + FEATURE_CARD_SIZES.map((size) => ( + + )); + +export const Unpadded: Story = { + render: () => ( + + {renderFeatureCardMatrix({ padded: false })} + + ), +}; + +export const PaddedHorizontalOnly: Story = { + render: () => ( + + {renderFeatureCardMatrix({ padded: 'horizontal' })} + + ), +}; + +export const PaddedVerticalOnly: Story = { + render: () => ( + + {renderFeatureCardMatrix({ padded: 'vertical' })} + + ), +}; + +export const CustomPaddingSize: Story = { + render: () => ( + + {FEATURE_CARD_SIZES.map((size) => ( + + ))} + + ), +}; diff --git a/src/stories/Panel/Panel.stories.tsx b/src/stories/Panel/Panel.stories.tsx index 1315eed7..f2cbee7c 100644 --- a/src/stories/Panel/Panel.stories.tsx +++ b/src/stories/Panel/Panel.stories.tsx @@ -961,3 +961,47 @@ export const Raised: Story = { padded: true, }, }; + +const ICON_LAYOUT_SIZES: IReqorePanelProps['size'][] = ['tiny', 'small', 'normal', 'big', 'huge']; + +const renderIconLayoutMatrix = ( + variantArgs: Partial, + variantLabel: string +): StoryFn => { + return (args: IReqorePanelProps) => ( +
    + {ICON_LAYOUT_SIZES.map((size) => ( + + ))} +
    + ); +}; + +export const IconWithLabel: Story = { + render: renderIconLayoutMatrix({ iconWithLabel: true }, 'iconWithLabel'), +}; + +export const IconAlignTop: Story = { + render: renderIconLayoutMatrix({ iconVerticalAlign: 'top' }, "iconVerticalAlign='top'"), +}; + +export const IconAlignCenter: Story = { + render: renderIconLayoutMatrix( + { iconVerticalAlign: 'center' }, + "iconVerticalAlign='center' (default)" + ), +}; + +export const IconAlignBottom: Story = { + render: renderIconLayoutMatrix({ iconVerticalAlign: 'bottom' }, "iconVerticalAlign='bottom'"), +}; diff --git a/src/stories/SeverityRow/SeverityRow.stories.tsx b/src/stories/SeverityRow/SeverityRow.stories.tsx index 47edc341..ef26a74b 100644 --- a/src/stories/SeverityRow/SeverityRow.stories.tsx +++ b/src/stories/SeverityRow/SeverityRow.stories.tsx @@ -1,7 +1,8 @@ import { StoryObj } from '@storybook/react'; import ReqoreControlGroup from '../../components/ControlGroup'; -import ReqoreSeverityRow from '../../components/SeverityRow'; +import ReqoreSeverityRow, { IReqoreSeverityRowProps } from '../../components/SeverityRow'; import ReqoreTag from '../../components/Tag'; +import { TSizes } from '../../constants/sizes'; import { StoryMeta } from '../utils'; const meta = { @@ -211,3 +212,60 @@ export const Raised: Story = { actions: [{ label: 'Investigate', intent: 'danger' }], }, }; + +const SEVERITY_ROW_SIZES: TSizes[] = ['tiny', 'small', 'normal', 'big', 'huge']; + +const renderSeverityRowMatrix = (variantArgs: Partial) => + SEVERITY_ROW_SIZES.map((size) => ( + } + size={size} + {...variantArgs} + /> + )); + +export const Unpadded: Story = { + render: () => ( + + {renderSeverityRowMatrix({ padded: false })} + + ), +}; + +export const PaddedHorizontalOnly: Story = { + render: () => ( + + {renderSeverityRowMatrix({ padded: 'horizontal' })} + + ), +}; + +export const PaddedVerticalOnly: Story = { + render: () => ( + + {renderSeverityRowMatrix({ padded: 'vertical' })} + + ), +}; + +export const CustomPaddingSize: Story = { + render: () => ( + + {SEVERITY_ROW_SIZES.map((size) => ( + } + size={size} + paddingSize='small' + /> + ))} + + ), +}; diff --git a/src/stories/Statistic/Statistic.stories.tsx b/src/stories/Statistic/Statistic.stories.tsx index f0649291..d07f2e51 100644 --- a/src/stories/Statistic/Statistic.stories.tsx +++ b/src/stories/Statistic/Statistic.stories.tsx @@ -1,6 +1,7 @@ import { StoryObj } from '@storybook/react'; import { useState } from 'react'; -import ReqoreStatistic from '../../components/Statistic'; +import ReqoreStatistic, { IReqoreStatisticProps } from '../../components/Statistic'; +import { TSizes } from '../../constants/sizes'; import { ReqoreControlGroup } from '../../index'; import { StoryMeta } from '../utils'; @@ -462,3 +463,60 @@ export const Raised: Story = { ), }; + +const STATISTIC_SIZES: TSizes[] = ['tiny', 'small', 'normal', 'big', 'huge']; + +const renderStatisticMatrix = (variantArgs: Partial) => + STATISTIC_SIZES.map((size) => ( + + )); + +export const Unpadded: Story = { + render: () => ( + + {renderStatisticMatrix({ padded: false })} + + ), +}; + +export const PaddedHorizontalOnly: Story = { + render: () => ( + + {renderStatisticMatrix({ padded: 'horizontal' })} + + ), +}; + +export const PaddedVerticalOnly: Story = { + render: () => ( + + {renderStatisticMatrix({ padded: 'vertical' })} + + ), +}; + +export const CustomPaddingSize: Story = { + render: () => ( + + {STATISTIC_SIZES.map((size) => ( + + ))} + + ), +}; diff --git a/src/stories/Testimonial/Testimonial.stories.tsx b/src/stories/Testimonial/Testimonial.stories.tsx new file mode 100644 index 00000000..e0033739 --- /dev/null +++ b/src/stories/Testimonial/Testimonial.stories.tsx @@ -0,0 +1,359 @@ +import { StoryObj } from '@storybook/react'; +import ReqoreControlGroup from '../../components/ControlGroup'; +import ReqoreTestimonial, { IReqoreTestimonialProps } from '../../components/Testimonial'; +import { TSizes } from '../../constants/sizes'; +import { StoryMeta } from '../utils'; + +const meta = { + title: 'Display/Testimonial/Stories', + component: ReqoreTestimonial, +} as StoryMeta; + +export default meta; +type Story = StoryObj; + +const SAMPLE_QUOTE = + 'Reqore lets our team ship dashboards in hours instead of days — the theming and effect system alone has saved us weeks of CSS work.'; + +export const Basic: Story = { + args: { + quote: SAMPLE_QUOTE, + author: 'Avery Chen', + role: 'Lead Engineer · Northwind', + avatarIcon: 'UserSmileLine', + rating: 5, + }, +}; + +export const WithAvatarImage: Story = { + args: { + quote: SAMPLE_QUOTE, + author: 'Mira Patel', + role: 'Director of Platform · Acme', + avatar: + 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=128&h=128&fit=crop&crop=face', + rating: 4.5, + }, +}; + +export const WithBadge: Story = { + args: { + quote: 'The migration story is solid — drop-in components, predictable theming.', + author: 'Jonas Weber', + role: 'Frontend Architect', + avatarIcon: 'UserSmileLine', + badge: { label: 'Customer', intent: 'success', minimal: true }, + rating: 5, + }, +}; + +export const WithActions: Story = { + args: { + quote: 'Best component library decision we made this year.', + author: 'Priya Raman', + role: 'Engineering Manager', + avatarIcon: 'UserSmileLine', + rating: 5, + actions: [ + { label: 'Read case study', icon: 'ExternalLinkLine' }, + { icon: 'ShareLine', tooltip: 'Share', minimal: true, flat: true }, + ], + }, +}; + +export const Intents: Story = { + render: (args) => ( + + + + + + + ), +}; + +export const Sizes: Story = { + render: () => ( + + + + + + + ), +}; + +export const Bordered: Story = { + args: { + quote: SAMPLE_QUOTE, + author: 'Avery Chen', + role: 'Lead Engineer', + avatarIcon: 'UserSmileLine', + intent: 'info', + flat: false, + }, +}; + +export const Square: Story = { + args: { + quote: SAMPLE_QUOTE, + author: 'Avery Chen', + role: 'Lead Engineer', + avatarIcon: 'UserSmileLine', + intent: 'success', + rounded: false, + }, +}; + +export const Transparent: Story = { + args: { + quote: SAMPLE_QUOTE, + author: 'Avery Chen', + role: 'Lead Engineer', + avatarIcon: 'UserSmileLine', + intent: 'info', + transparent: true, + }, +}; + +export const NoQuoteIcon: Story = { + args: { + quote: SAMPLE_QUOTE, + author: 'Avery Chen', + role: 'Lead Engineer', + avatarIcon: 'UserSmileLine', + showQuoteIcon: false, + }, +}; + +export const NoRating: Story = { + args: { + quote: SAMPLE_QUOTE, + author: 'Avery Chen', + role: 'Lead Engineer', + avatarIcon: 'UserSmileLine', + }, +}; + +export const QuoteOnly: Story = { + args: { + quote: 'Ship faster. Stay consistent. Reqore.', + }, +}; + +export const Disabled: Story = { + args: { + quote: SAMPLE_QUOTE, + author: 'Avery Chen', + role: 'Lead Engineer', + avatarIcon: 'UserSmileLine', + disabled: true, + actions: [{ label: 'Read more' }], + }, +}; + +export const Tooltip: Story = { + args: { + quote: 'Hover the card to see the tooltip.', + author: 'Avery Chen', + role: 'Lead Engineer', + avatarIcon: 'UserSmileLine', + tooltip: 'Submitted via the customer feedback form.', + }, +}; + +export const Clickable: Story = { + args: { + quote: 'Click anywhere on the card to read the full story.', + author: 'Avery Chen', + role: 'Lead Engineer', + avatarIcon: 'UserSmileLine', + rating: 5, + onClick: () => alert('Card clicked'), + }, +}; + +export const WithEffects: Story = { + args: { + quote: SAMPLE_QUOTE, + author: 'Avery Chen', + role: 'Lead Engineer', + avatarIcon: 'UserSmileLine', + intent: 'info', + rating: 5, + effect: { + gradient: { + colors: { 0: 'info:darken:5', 100: 'transparent' }, + direction: 'to bottom right', + }, + }, + quoteEffect: { italic: true }, + authorEffect: { uppercase: true, spaced: 1, weight: 'bold' }, + roleEffect: { opacity: 0.6 }, + }, +}; + +export const CustomTheme: Story = { + args: { + quote: SAMPLE_QUOTE, + author: 'Avery Chen', + role: 'Lead Engineer', + avatarIcon: 'UserSmileLine', + customTheme: { main: '#2c1a4d' }, + rating: 5, + }, +}; + +export const Raised: Story = { + args: { + quote: SAMPLE_QUOTE, + author: 'Avery Chen', + role: 'Lead Engineer', + avatarIcon: 'UserSmileLine', + rating: 5, + raised: true, + }, +}; + +export const NoWrap: Story = { + args: { + quote: + 'A very long quote that would normally span multiple lines but here is forced onto a single line and ellipsized at the boundary of the available width.', + author: 'Avery Chen', + role: 'Lead Engineer', + avatarIcon: 'UserSmileLine', + wrap: false, + }, + decorators: [ + (Story) => ( +
    + +
    + ), + ], +}; + +export const Fixed: Story = { + args: { + quote: SAMPLE_QUOTE, + author: 'Avery Chen', + role: 'Lead Engineer', + avatarIcon: 'UserSmileLine', + fixed: true, + }, +}; + +const TESTIMONIAL_SIZES: TSizes[] = ['tiny', 'small', 'normal', 'big', 'huge']; + +const renderTestimonialMatrix = (variantArgs: Partial) => + TESTIMONIAL_SIZES.map((size) => ( + + )); + +export const Unpadded: Story = { + render: () => ( + + {renderTestimonialMatrix({ padded: false })} + + ), +}; + +export const PaddedHorizontalOnly: Story = { + render: () => ( + + {renderTestimonialMatrix({ padded: 'horizontal' })} + + ), +}; + +export const PaddedVerticalOnly: Story = { + render: () => ( + + {renderTestimonialMatrix({ padded: 'vertical' })} + + ), +}; + +export const CustomPaddingSize: Story = { + render: () => ( + + {TESTIMONIAL_SIZES.map((size) => ( + + ))} + + ), +};