diff --git a/__tests__/buttonBadgeSize.test.tsx b/__tests__/buttonBadgeSize.test.tsx
new file mode 100644
index 00000000..6f15a6a7
--- /dev/null
+++ b/__tests__/buttonBadgeSize.test.tsx
@@ -0,0 +1,59 @@
+import { render } from '@testing-library/react';
+import { ReactNode } from 'react';
+import { ReqoreContent, ReqoreLayoutContent, ReqoreTier, ReqoreUIProvider } from '../src';
+import { ButtonBadge } from '../src/components/Button';
+
+/**
+ * A badge spaced from what precedes it sized the gap from its `size`. Rendered without one —
+ * as `ReqoreTier` did — the gap was `PADDING_FROM_SIZE[undefined] / 1`, i.e. `width: NaNpx`,
+ * which the browser drops (and React warned about while the spacer still wrote `width`).
+ */
+
+const renderInProvider = (node: ReactNode) =>
+ render(
+
+
+ {node}
+
+
+ );
+
+/**
+ * Spacers whose size came out as no number: a spacer given a `NaN` width is neither horizontal
+ * nor vertical, so its space gets no width at all.
+ */
+const getUnsizedSpacers = (): HTMLElement[] =>
+ Array.from(document.querySelectorAll('.reqore-spacer > div')).filter(
+ (space) => getComputedStyle(space).width === 'auto'
+ );
+
+const getNaNWarnings = (): unknown[][] =>
+ vi
+ .mocked(console.error)
+ .mock.calls.filter((args) => typeof args[0] === 'string' && args[0].includes('NaN'));
+
+beforeEach(() => {
+ vi.mocked(console.error).mockClear();
+});
+
+test('a badge without a size spaces itself like a normal-size one', () => {
+ renderInProvider();
+
+ const space = document.querySelector('.reqore-spacer > div') as HTMLElement;
+
+ expect(space).toBeTruthy();
+ // `normal` padding (8px), halved by the horizontal spacer's two sides.
+ expect(getComputedStyle(space).width).toBe('4px');
+ expect(getUnsizedSpacers()).toEqual([]);
+ expect(getNaNWarnings()).toEqual([]);
+});
+
+test('a Tier with a badge computes no NaN size', () => {
+ renderInProvider(
+
+ );
+
+ expect(document.querySelector('.reqore-button-badge')).toBeTruthy();
+ expect(getUnsizedSpacers()).toEqual([]);
+ expect(getNaNWarnings()).toEqual([]);
+});
diff --git a/__tests__/domPropLeaks.test.tsx b/__tests__/domPropLeaks.test.tsx
new file mode 100644
index 00000000..13a6162b
--- /dev/null
+++ b/__tests__/domPropLeaks.test.tsx
@@ -0,0 +1,456 @@
+import isPropValid from '@emotion/is-prop-valid';
+import { render } from '@testing-library/react';
+import { ReactElement } from 'react';
+import {
+ DatePicker,
+ ReqoreButton,
+ ReqoreCheckbox,
+ ReqoreCollection,
+ ReqoreContent,
+ ReqoreControlGroup,
+ ReqoreHeader,
+ ReqoreInput,
+ ReqoreKeyValueTable,
+ ReqoreLayoutContent,
+ ReqoreLink,
+ ReqoreMenu,
+ ReqoreMenuItem,
+ ReqoreMessage,
+ ReqoreNavbarGroup,
+ ReqoreNavbarItem,
+ ReqoreP,
+ ReqorePanel,
+ ReqorePopover,
+ ReqoreSlider,
+ ReqoreSpacer,
+ ReqoreTabs,
+ ReqoreTabsContent,
+ ReqoreTag,
+ ReqoreTimeline,
+ ReqoreTree,
+ ReqoreUIProvider,
+} from '../src';
+import { REQORE_CONTROL_GROUP_CHILD_PROPS } from '../src/helpers/styled';
+
+/**
+ * Styling props must not reach the DOM.
+ *
+ * Reqore styles its components with props (`fill`, `wrap`, `color`, `opacity`, `theme`, …), and
+ * styled-components decides which of them the rendered element gets. A DOM tag only gets what
+ * looks like an HTML attribute, which still lets through the names that ARE attributes somewhere
+ * (`fill`, `wrap`, `color`, `width`, `direction` …); a component given as `as` (`animated.div`,
+ * re-resizable, react-aria's `DateInput`, a router link) gets everything and writes it onto its
+ * own element. React then warns ("React does not recognize the `hasIcon` prop", "Received `true`
+ * for a non-boolean attribute `fill`") or silently writes `theme="[object Object]"`.
+ *
+ * React only warns about a prop name ONCE per page, so a console spy cannot tell the second leak
+ * of `fill` from the first. This test reads what React actually handed each element instead:
+ * React 18 keeps an element's current props on the node, under a `__reactProps$` key.
+ */
+
+/** Attributes that exist, but not on the HTML elements a Reqore component renders. */
+const NOT_ON_HTML: Record = {
+ // SVG presentation attributes.
+ fill: [],
+ opacity: [],
+ direction: [],
+ offset: [],
+ spacing: [],
+ // Legacy `` only.
+ color: [],
+ wrap: ['textarea'],
+ width: ['img', 'canvas', 'video', 'iframe', 'input', 'embed', 'object', 'col', 'colgroup'],
+ height: ['img', 'canvas', 'video', 'iframe', 'input', 'embed', 'object'],
+ type: [
+ 'input',
+ 'button',
+ 'ol',
+ 'li',
+ 'a',
+ 'source',
+ 'embed',
+ 'object',
+ 'script',
+ 'style',
+ 'link',
+ ],
+ value: [
+ 'input',
+ 'button',
+ 'select',
+ 'textarea',
+ 'option',
+ 'li',
+ 'meter',
+ 'progress',
+ 'param',
+ 'data',
+ 'output',
+ ],
+};
+
+const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
+
+const getReactProps = (element: Element): Record | undefined => {
+ const key = Object.keys(element).find((name) => name.startsWith('__reactProps$'));
+
+ return key ? (element as unknown as Record>)[key] : undefined;
+};
+
+const describeElement = (element: Element): string => {
+ const reqoreClass = Array.from(element.classList).find((name) => name.startsWith('reqore-'));
+
+ return `<${element.tagName.toLowerCase()}${reqoreClass ? `.${reqoreClass}` : ''}>`;
+};
+
+/** Every non-DOM prop React was handed for an element of the document, as ` prop`. */
+const findLeakedProps = (): string[] => {
+ const leaks: string[] = [];
+
+ document.body.querySelectorAll('*').forEach((element) => {
+ const props = getReactProps(element);
+
+ if (!props || element.namespaceURI === SVG_NAMESPACE) {
+ return;
+ }
+
+ const tag = element.tagName.toLowerCase();
+
+ Object.entries(props).forEach(([prop, value]) => {
+ // React renders nothing for an absent value, and says nothing about it either.
+ if (value === undefined || value === null) {
+ return;
+ }
+
+ if (prop === 'children' || prop.startsWith('data-') || prop.startsWith('aria-')) {
+ return;
+ }
+
+ const allowedOn = NOT_ON_HTML[prop];
+
+ if (!isPropValid(prop) || (allowedOn && !allowedOn.includes(tag))) {
+ leaks.push(`${describeElement(element)} ${prop}`);
+ }
+ });
+ });
+
+ return [...new Set(leaks)].sort();
+};
+
+const REACT_DOM_PROP_WARNINGS = [
+ /does not recognize the `/,
+ /for a non-boolean attribute/,
+ /Invalid value for prop/,
+ /Unknown event handler property/,
+ /Received NaN for the/,
+];
+
+const formatConsoleCall = ([format, ...args]: unknown[]): string => {
+ let index = 0;
+
+ return typeof format === 'string'
+ ? format.replace(/%s/g, () => String(args[index++]))
+ : String(format);
+};
+
+const getReactDomPropWarnings = (): string[] =>
+ vi
+ .mocked(console.error)
+ .mock.calls.map(formatConsoleCall)
+ .filter((message) => REACT_DOM_PROP_WARNINGS.some((pattern) => pattern.test(message)));
+
+const renderInProvider = (element: ReactElement) =>
+ render(
+
+
+ {element}
+
+
+ );
+
+/** A router's link: it writes every prop it does not know onto its ``. */
+const RouterLinkStub = ({ to, children, ...rest }: any) => (
+
+ {children}
+
+);
+
+// `fill` is not a checkbox prop, but a containing control group hands it to every child, and
+// consumers pass it the same way.
+const FILL = { fill: true } as any;
+
+const CASES: [string, () => ReactElement][] = [
+ ['a bare DatePicker', () => ],
+ [
+ 'a minimal pill DatePicker with time',
+ () => ,
+ ],
+ [
+ 'filling Tabs',
+ () => (
+
+ One
+ Two
+
+ ),
+ ],
+ [
+ 'a filling Collection with a height',
+ () => (
+
+ ),
+ ],
+ ['a Checkbox given `fill`', () => ],
+ [
+ 'a filling ControlGroup of Tag, Paragraph, Input, Checkbox and Button',
+ () => (
+
+
+ Paragraph
+
+
+
+ Button
+
+
+ ),
+ ],
+ [
+ 'a Slider in a filling ControlGroup',
+ () => (
+
+
+
+ ),
+ ],
+ [
+ 'a resizable Menu with a width',
+ () => (
+
+
+
+ ),
+ ],
+ [
+ 'a Link rendered as a router link',
+ () => (
+
+ Go to issues
+
+ ),
+ ],
+ [
+ 'a Message with a tooltip',
+ () => (
+
+ Message
+
+ ),
+ ],
+ [
+ 'an open Popover whose content is a DOM element',
+ () => (
+ Text}
+ >
+ Open
+
+ ),
+ ],
+ [
+ 'an open Popover whose content is a Message',
+ () => (
+ Message}
+ >
+ Open
+
+ ),
+ ],
+ [
+ 'a Timeline with tooltips',
+ () => (
+
+ ),
+ ],
+ [
+ 'a Navbar',
+ () => (
+
+
+ Item
+
+
+ ),
+ ],
+ ['a Tree given a `mode`', () => ],
+ [
+ 'a Panel with an opacity and bottom actions',
+ () => (
+
+ Content
+
+ ),
+ ],
+ [
+ 'a resizable, rounded, interactive Panel',
+ () => (
+
+ Content
+
+ ),
+ ],
+ ['a KeyValueTable', () => ],
+ ['a Spacer', () => ],
+];
+
+describe('no styling prop reaches the DOM', () => {
+ const originalResizeObserver = globalThis.ResizeObserver;
+
+ beforeAll(() => {
+ // jsdom has none; the slider measures its thumbs with one.
+ globalThis.ResizeObserver = class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ } as unknown as typeof ResizeObserver;
+ });
+
+ afterAll(() => {
+ globalThis.ResizeObserver = originalResizeObserver;
+ });
+
+ beforeEach(() => {
+ vi.mocked(console.error).mockClear();
+ });
+
+ test.each(CASES)('%s', (_name, element) => {
+ renderInProvider(element());
+
+ expect(findLeakedProps()).toEqual([]);
+ expect(getReactDomPropWarnings()).toEqual([]);
+ });
+});
+
+describe('the leak detector', () => {
+ // Without these, a detector that found nothing would pass every case above.
+ test('finds a styling flag a DOM element was given', () => {
+ renderInProvider(
+
+ );
+
+ expect(findLeakedProps()).toEqual([
+ ' fill',
+ ' hasIcon',
+ ' width',
+ ]);
+ });
+
+ test('leaves real attributes, data and aria attributes, and SVG presentation alone', () => {
+ renderInProvider(
+
+ );
+
+ expect(findLeakedProps()).toEqual([]);
+ });
+});
+
+test('REQORE_CONTROL_GROUP_CHILD_PROPS names every prop a control group hands its children', () => {
+ // The children filter these props by that list; a prop the group starts handing out without
+ // being added to it would reach their elements again.
+ const handedOut = new Set();
+ const Probe = (props: Record) => {
+ Object.keys(props).forEach((prop) => handedOut.add(prop));
+
+ return null;
+ };
+ const groupProps = {
+ fill: true,
+ fluid: true,
+ fixed: true,
+ flat: true,
+ minimal: true,
+ intent: 'info' as const,
+ size: 'small' as const,
+ customTheme: { main: '#ff0000' as const },
+ };
+
+ renderInProvider(
+ <>
+
+
+
+
+
+
+
+ >
+ );
+
+ // `style` is the stack's merged corner radii, which the element is meant to get.
+ handedOut.delete('style');
+
+ expect([...handedOut].sort()).toEqual([...REQORE_CONTROL_GROUP_CHILD_PROPS].sort());
+});
diff --git a/__tests__/panelAs.test.tsx b/__tests__/panelAs.test.tsx
new file mode 100644
index 00000000..25eaf3ae
--- /dev/null
+++ b/__tests__/panelAs.test.tsx
@@ -0,0 +1,86 @@
+import { render } from '@testing-library/react';
+import { forwardRef, ReactNode } from 'react';
+import { ReqoreLayoutContent, ReqorePanel, ReqoreUIProvider } from '../src';
+
+/**
+ * A panel renders the caller's `as` when one is given, re-resizable's `Resizable` when it is
+ * resizable, and a `div` otherwise. `as={rest.as || resizable ? Resizable : 'div'}` binds as
+ * `(rest.as || resizable) ? Resizable : 'div'`, so a panel given `as` rendered a `Resizable`
+ * and the caller's element never appeared.
+ */
+
+const renderPanel = (panel: ReactNode) =>
+ render(
+
+ {panel}
+
+ );
+
+const getPanel = () => document.querySelector('.reqore-panel') as HTMLElement;
+
+// re-resizable draws each enabled handle as a div with an inline `*-resize` cursor.
+const getResizeHandles = () =>
+ Array.from(document.querySelectorAll('*')).filter((element) =>
+ /-resize$/.test(element.style?.cursor || '')
+ );
+
+const Section = forwardRef(
+ ({ children, className }, ref) => (
+
+ )
+);
+
+test('a panel given an element in `as` renders that element', () => {
+ renderPanel(
+
+ Body
+
+ );
+
+ expect(getPanel().tagName).toBe('SECTION');
+ expect(getResizeHandles()).toHaveLength(0);
+});
+
+test('a panel given a component in `as` renders that component', () => {
+ renderPanel(
+
+ Body
+
+ );
+
+ expect(getPanel().hasAttribute('data-custom-panel')).toBe(true);
+ expect(getResizeHandles()).toHaveLength(0);
+});
+
+test('`as` wins over `resizable`', () => {
+ renderPanel(
+
+ Body
+
+ );
+
+ expect(getPanel().tagName).toBe('SECTION');
+ expect(getResizeHandles()).toHaveLength(0);
+ // re-resizable's own props are not spread onto the caller's element either.
+ expect(getPanel().hasAttribute('enable')).toBe(false);
+});
+
+test('a resizable panel without `as` renders a Resizable', () => {
+ renderPanel(
+
+ Body
+
+ );
+
+ expect(getPanel().tagName).toBe('DIV');
+ expect(getResizeHandles().map((element) => element.style.cursor)).toEqual(['row-resize']);
+});
+
+test('a panel that is neither renders a div', () => {
+ renderPanel(Body);
+
+ expect(getPanel().tagName).toBe('DIV');
+ expect(getResizeHandles()).toHaveLength(0);
+});
diff --git a/package.json b/package.json
index bc3eeedb..11511f4b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@qoretechnologies/reqore",
- "version": "0.77.5",
+ "version": "0.77.7",
"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/Button/index.tsx b/src/components/Button/index.tsx
index c80841b5..ce21c0b6 100644
--- a/src/components/Button/index.tsx
+++ b/src/components/Button/index.tsx
@@ -337,7 +337,8 @@ export const StyledButton = styled(StyledEffect).withConfig({
// A containing ControlGroup can propagate its `fill` layout flag through
// polymorphic controls such as Dropdown. The flag is meaningful to the
// group, but it is not a valid boolean attribute for the rendered button.
- shouldForwardProp: omitStyleProps('fill'),
+ // `color` is the button's colour and `wrap` how its label breaks, both for its styles.
+ shouldForwardProp: omitStyleProps('fill', 'color', 'wrap'),
})`
display: flex;
flex-flow: column;
@@ -551,7 +552,9 @@ export const StyledButton = styled(StyledEffect).withConfig({
}
`;
-export const StyledButtonContent = styled.div`
+export const StyledButtonContent = styled.div.withConfig({
+ shouldForwardProp: omitStyleProps('wrap'),
+})`
display: flex;
align-items: center;
width: 100%;
@@ -580,7 +583,16 @@ export interface IReqoreButtonBadgeProps extends IWithReqoreSize {
}
export const ButtonBadge = memo(
- ({ wrapGroup, compact, active, margin = 'left', ...props }: IReqoreButtonBadgeProps) => {
+ ({
+ wrapGroup,
+ compact,
+ active,
+ margin = 'left',
+ // Without a size the spacer before the badge computed `NaNpx`.
+ size: badgeSize = 'normal',
+ ...rest
+ }: IReqoreButtonBadgeProps) => {
+ const props = { ...rest, size: badgeSize };
const renderTag = useCallback(
({ size, color, theme, content, key }: IReqoreButtonBadgeProps & { key: number }) => (
),
- [props]
+ // The tag is built from its arguments; only `active` comes from the closure.
+ [active]
);
- const content = Array.isArray(props.content) ? props.content : [props.content];
+ const content = useMemo(
+ () => (Array.isArray(rest.content) ? rest.content : [rest.content]),
+ [rest.content]
+ );
const leftBadges = useMemo(
() =>
diff --git a/src/components/Checkbox/index.tsx b/src/components/Checkbox/index.tsx
index 105900d9..ebc85d33 100644
--- a/src/components/Checkbox/index.tsx
+++ b/src/components/Checkbox/index.tsx
@@ -16,6 +16,7 @@ import {
getReadableColor,
getReadableColorFrom,
} from '../../helpers/colors';
+import { omitStyleProps } from '../../helpers/styled';
import { getOneLessSize } from '../../helpers/utils';
import { useComponentTooltip } from '../../hooks/useComponentTooltip';
import { useReqoreTheme } from '../../hooks/useTheme';
@@ -110,7 +111,9 @@ export interface IReqoreCheckboxStyle extends IReqoreCheckboxProps {
theme: IReqoreTheme;
}
-const StyledSwitchToggle = styled.div`
+const StyledSwitchToggle = styled.div.withConfig({
+ shouldForwardProp: omitStyleProps('width'),
+})`
transition: all 0.2s ease-in-out;
content: '';
display: flex;
@@ -230,7 +233,11 @@ const StyledOffSwitchText = styled(StyledSwitchTextWrapper)`
+// The row also receives what a `ReqoreControlGroup` hands every child (`fill`, `stack`, …);
+// `fill` is the one a `div` would otherwise keep, being an SVG attribute.
+const StyledCheckbox = styled.div.withConfig({
+ shouldForwardProp: omitStyleProps('fill'),
+})`
display: inline-flex;
align-items: center;
cursor: pointer;
diff --git a/src/components/Collection/index.tsx b/src/components/Collection/index.tsx
index 28a64e3a..b48180ea 100644
--- a/src/components/Collection/index.tsx
+++ b/src/components/Collection/index.tsx
@@ -7,6 +7,7 @@ import { ReqoreErrorBoundary, ReqoreP, useReqoreProperty } from '../..';
import { TReqorePaginationType } from '../../constants/paging';
import { PADDING_FROM_SIZE } from '../../constants/sizes';
import { ReqorePaginationContainer } from '../../containers/Paging';
+import { omitStyleProps } from '../../helpers/styled';
import { useQueryWithDelay } from '../../hooks/useQueryWithDelay';
import { IReqoreComponent } from '../../types/global';
import { IReqoreIconName } from '../../types/icons';
@@ -125,7 +126,9 @@ export interface IReqoreCollectionProps
paging?: TReqorePaginationType;
}
-export const StyledCollectionWrapper = styled(StyledColumns)`
+export const StyledCollectionWrapper = styled(StyledColumns).withConfig({
+ shouldForwardProp: omitStyleProps('fill', 'height'),
+})`
height: ${({ height }) => (height ? `${height}` : 'auto')};
flex: ${({ fill }) => (fill ? 1 : undefined)};
diff --git a/src/components/Collection/item.tsx b/src/components/Collection/item.tsx
index 933f57c0..eb8ca501 100644
--- a/src/components/Collection/item.tsx
+++ b/src/components/Collection/item.tsx
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react';
import { useDebounce, useMeasure } from 'react-use';
import styled, { css } from 'styled-components';
import { changeDarkness, getMainBackgroundColor } from '../../helpers/colors';
+import { omitStyleProps } from '../../helpers/styled';
import { useReqoreProperty } from '../../hooks/useReqoreContext';
import { useReqoreTheme } from '../../hooks/useTheme';
import { ReqoreBackdrop } from '../Drawer/backdrop';
@@ -54,7 +55,9 @@ export interface IReqoreCollectionItemProps
groups?: string[];
}
-export const StyledCollectionItemContent = styled.div`
+export const StyledCollectionItemContent = styled.div.withConfig({
+ shouldForwardProp: omitStyleProps('opacity'),
+})`
max-height: ${({ providedHeight }) => (providedHeight ? `${providedHeight}px` : 'auto')};
overflow: ${({ isSelected }) => (isSelected ? 'auto' : 'hidden')};
position: relative;
diff --git a/src/components/ControlGroup/index.tsx b/src/components/ControlGroup/index.tsx
index d94a41c0..8d72d5dd 100644
--- a/src/components/ControlGroup/index.tsx
+++ b/src/components/ControlGroup/index.tsx
@@ -554,6 +554,8 @@ const ReqoreControlGroup = memo(
: {
...props,
key: props?.reactKey || _index,
+ // Keep in step with `REQORE_CONTROL_GROUP_CHILD_PROPS`, which the children use to
+ // keep these off the DOM.
...definedOnly({
minimal: props?.minimal ?? minimal,
size: props?.size || size,
@@ -598,6 +600,7 @@ const ReqoreControlGroup = memo(
borderBottomRightRadius: getBorderBottomRightRadius(index, props?.rounded) ?? 0,
...(props?.style || {}),
},
+ // Also in `REQORE_CONTROL_GROUP_CHILD_PROPS`.
...(!isIntrinsicElement
? {
isChild: true,
diff --git a/src/components/ControlGroup/item.tsx b/src/components/ControlGroup/item.tsx
index 5500d633..2cf77c83 100644
--- a/src/components/ControlGroup/item.tsx
+++ b/src/components/ControlGroup/item.tsx
@@ -1,7 +1,7 @@
import { forwardRef, memo, ReactNode } from 'react';
import styled from 'styled-components';
import { GAP_FROM_SIZE, TSizes } from '../../constants/sizes';
-import { omitStyleProps } from '../../helpers/styled';
+import { omitStyleProps, REQORE_CONTROL_GROUP_CHILD_PROPS } from '../../helpers/styled';
import { IWithReqoreFluid, IWithReqoreSize } from '../../types/global';
export interface IReqoreControlGroupItemProps
@@ -38,20 +38,7 @@ export interface IReqoreControlGroupItemProps
}
export const StyledReqoreControlGroupItem = styled.div.withConfig({
- shouldForwardProp: omitStyleProps(
- 'customTheme',
- 'fill',
- 'fixed',
- 'flat',
- 'fluid',
- 'gapSize',
- 'horizontal',
- 'intent',
- 'minimal',
- 'size',
- 'spaceBetween',
- 'stack'
- ),
+ shouldForwardProp: omitStyleProps(...REQORE_CONTROL_GROUP_CHILD_PROPS, 'gapSize', 'horizontal'),
})`
display: flex;
flex-flow: ${({ horizontal }) => (horizontal ? 'row' : 'column')};
diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx
index 0bf510bb..37842e0d 100644
--- a/src/components/DatePicker/index.tsx
+++ b/src/components/DatePicker/index.tsx
@@ -23,6 +23,7 @@ import styled from 'styled-components';
import { ReqoreErrorBoundary, ReqorePanel, ReqorePopover } from '../..';
import { changeLightness } from '../../helpers/colors';
import { formatDateToType, TDateFormat, toDate } from '../../helpers/dates';
+import { omitStyleProps } from '../../helpers/styled';
import { useComponentTooltip } from '../../hooks/useComponentTooltip';
import { useReqoreTheme } from '../../hooks/useTheme';
import { DisabledElement } from '../../styles';
@@ -139,7 +140,12 @@ const StyledDateSegment: typeof DateSegment = styled(DateSegment)`
border-radius: 4px;
}
`;
-const StyledDateInput: typeof DateInput = styled(DateInput)`
+/* Rendered by `ReqoreInput` (its `as`), which hands it the field's `value` for the clear
+ button. The value belongs to the date field around it; `DateInput` would write it onto
+ its segment group. */
+const StyledDateInput: typeof DateInput = styled(DateInput).withConfig({
+ shouldForwardProp: omitStyleProps('value'),
+})`
display: inline-flex;
align-items: center;
`;
diff --git a/src/components/Effect/index.tsx b/src/components/Effect/index.tsx
index 7d1866f6..e170a5e5 100644
--- a/src/components/Effect/index.tsx
+++ b/src/components/Effect/index.tsx
@@ -12,6 +12,7 @@ import {
getGradientMix,
getReadableColorFrom,
} from '../../helpers/colors';
+import { omitStyleProps } from '../../helpers/styled';
import { isStringSize } from '../../helpers/utils';
import { IWithReqoreMinimal } from '../../types/global';
@@ -391,7 +392,19 @@ export const withGlow = (theme: IReqoreTheme, effect: IReqoreEffect | undefined,
}
};
-export const StyledEffect = styled.span`
+/**
+ * Props every effect-based component styles itself with, and nothing it renders should get.
+ * A DOM tag drops most of them on its own, but `color` is an HTML attribute, and a component
+ * given as `as` (`animated.div`, re-resizable, a router link, react-aria's `DateInput`) receives
+ * every prop and writes them onto its own element — `theme="[object Object]"` and the like.
+ * Components built on `StyledEffect` inherit this filter (styled-components combines it with
+ * their own `shouldForwardProp`).
+ */
+const REQORE_EFFECT_STYLE_PROPS = ['color', 'effect', 'intent', 'isText', 'theme'];
+
+export const StyledEffect = styled.span.withConfig({
+ shouldForwardProp: omitStyleProps(...REQORE_EFFECT_STYLE_PROPS),
+})`
// If gradient was supplied
${({ effect, theme, minimal, active, transparent, isText }: IReqoreTextEffectProps) => {
const gradients = normalizeGradients(effect?.gradient);
diff --git a/src/components/Icon/index.tsx b/src/components/Icon/index.tsx
index 0d946ec9..512ac8da 100644
--- a/src/components/Icon/index.tsx
+++ b/src/components/Icon/index.tsx
@@ -90,6 +90,8 @@ export const StyledIconWrapper = styled(StyledEffect).withConfig({
'animation',
'compact',
'effect',
+ // Handed down by a containing `ReqoreControlGroup`.
+ 'fill',
'interactive',
'margin',
'marginSize',
diff --git a/src/components/Input/index.tsx b/src/components/Input/index.tsx
index 3e65c975..10b88fb3 100644
--- a/src/components/Input/index.tsx
+++ b/src/components/Input/index.tsx
@@ -13,6 +13,7 @@ import {
} from '../../constants/sizes';
import { IReqoreTheme } from '../../constants/theme';
import { changeLightness, getReadableColor } from '../../helpers/colors';
+import { omitStyleProps } from '../../helpers/styled';
import { IReqoreAutoFocusRules, useAutoFocus } from '../../hooks/useAutoFocus';
import { useCombinedRefs } from '../../hooks/useCombinedRefs';
import { useReqoreProperty } from '../../hooks/useReqoreContext';
@@ -115,7 +116,10 @@ export interface IReqoreInputStyle extends IReqoreInputProps {
hasPasswordToggle?: boolean;
}
-export const StyledInputWrapper = styled.div`
+// `width` is the field's CSS width; on a `div` it is not an attribute.
+export const StyledInputWrapper = styled.div.withConfig({
+ shouldForwardProp: omitStyleProps('width'),
+})`
height: ${({ _size }) => SIZE_TO_PX[_size]}px;
width: ${({ width }) => (width ? `${width}px` : 'auto')};
max-width: ${({ fluid, fixed }) => (fluid && !fixed ? '100%' : undefined)};
@@ -144,7 +148,9 @@ export const StyledInputWrapper = styled.div`
}
`;
-const StyledIconWrapper = styled.div`
+const StyledIconWrapper = styled.div.withConfig({
+ shouldForwardProp: omitStyleProps('offset'),
+})`
position: absolute;
height: ${({ _size }) => SIZE_TO_PX[_size]}px;
width: ${({ _size }) => SIZE_TO_PX[_size]}px;
@@ -155,7 +161,9 @@ const StyledIconWrapper = styled.div`
align-items: center;
`;
-const StyledShortcutWrapper = styled.div<{ _size: TSizes; offset: number }>`
+const StyledShortcutWrapper = styled.div.withConfig({
+ shouldForwardProp: omitStyleProps('offset'),
+})<{ _size: TSizes; offset: number }>`
position: absolute;
height: 100%;
top: 0;
@@ -165,7 +173,31 @@ const StyledShortcutWrapper = styled.div<{ _size: TSizes; offset: number }>`
pointer-events: none;
`;
-export const StyledInput = styled(StyledEffect)`
+/* The input's styling flags must stop here (`StyledEffect` already stops `theme`, `effect` and
+ the rest of its own). A DOM `input` drops most of them on its own, but not `fill` (an SVG
+ attribute), and a component given as `as` — the DatePicker's react-aria `DateInput` —
+ receives every prop and writes them onto its own element. */
+export const StyledInput = styled(StyledEffect).withConfig({
+ shouldForwardProp: omitStyleProps(
+ '_size',
+ 'clearable',
+ 'fill',
+ 'fixed',
+ 'flat',
+ 'fluid',
+ 'hasIcon',
+ 'hasPasswordToggle',
+ 'hasRightIcon',
+ 'hasShortcutHint',
+ 'minimal',
+ 'pill',
+ 'rounded',
+ 'spaceBetween',
+ 'stack',
+ 'tooltip',
+ 'transparent'
+ ),
+})`
height: 100%;
width: 100%;
flex: 1;
diff --git a/src/components/InternalPopover/index.tsx b/src/components/InternalPopover/index.tsx
index 0e09b11e..43becfb3 100644
--- a/src/components/InternalPopover/index.tsx
+++ b/src/components/InternalPopover/index.tsx
@@ -582,9 +582,12 @@ const InternalPopover: React.FC = memo(
<>
{React.Children.map(content, (child) =>
child
- ? React.cloneElement(child, {
- closePopover,
- })
+ ? // A DOM element cannot use it, and React would warn about the prop.
+ React.isValidElement(child) && typeof child.type === 'string'
+ ? child
+ : React.cloneElement(child, {
+ closePopover,
+ })
: null
)}
>
diff --git a/src/components/Menu/divider.tsx b/src/components/Menu/divider.tsx
index 37644392..f053ea4e 100644
--- a/src/components/Menu/divider.tsx
+++ b/src/components/Menu/divider.tsx
@@ -2,6 +2,7 @@ import React, { forwardRef } from 'react';
import styled, { css } from 'styled-components';
import { PADDING_FROM_SIZE } from '../../constants/sizes';
import { getReadableColor } from '../../helpers/colors';
+import { omitStyleProps } from '../../helpers/styled';
import { getOneLessSize, isStringSize } from '../../helpers/utils';
import { IWithReqoreEffect, IWithReqoreSize } from '../../types/global';
import { IReqoreEffect, StyledTextEffect } from '../Effect';
@@ -18,7 +19,11 @@ export interface IReqoreMenuDividerProps
line?: boolean;
}
-export const StyledMenuDivider = styled(StyledTextEffect)`
+// A menu hands every child its `wrap`; the divider has no text to wrap, and a `div` would
+// keep the attribute.
+export const StyledMenuDivider = styled(StyledTextEffect).withConfig({
+ shouldForwardProp: omitStyleProps('wrap'),
+})`
width: 100%;
padding: 0;
padding-top: ${({ padded, size }) =>
diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx
index 551ec529..a13aa3e1 100644
--- a/src/components/Menu/index.tsx
+++ b/src/components/Menu/index.tsx
@@ -6,6 +6,7 @@ import { HALF_PADDING_FROM_SIZE, RADIUS_FROM_SIZE } from '../../constants/sizes'
import { IReqoreCustomTheme, IReqoreTheme, TReqoreIntent } from '../../constants/theme';
import ReqoreThemeProvider from '../../containers/ThemeProvider';
import { changeDarkness, changeLightness, getMainBackgroundColor } from '../../helpers/colors';
+import { omitStyleProps } from '../../helpers/styled';
import { useCloneThroughFragments } from '../../hooks/useCloneThroughFragments';
import { useCombinedRefs } from '../../hooks/useCombinedRefs';
import { useReqoreTheme } from '../../hooks/useTheme';
@@ -50,7 +51,29 @@ export interface IReqoreMenuStyle extends IReqoreMenuProps {
theme: IReqoreTheme;
}
-const StyledReqoreMenu = styled(StyledEffect)`
+/* Rendered as re-resizable's `Resizable` when the menu is resizable, which writes every prop it
+ does not know onto its `div`: the menu's styling flags stop here. Resizable's own props
+ (`size`, `minWidth`, `maxHeight`, …) still pass. */
+const StyledReqoreMenu = styled(StyledEffect).withConfig({
+ shouldForwardProp: omitStyleProps(
+ '_size',
+ 'fill',
+ 'fixed',
+ 'flat',
+ 'fluid',
+ 'isResizableLeft',
+ 'isResizableRight',
+ 'minimal',
+ 'padded',
+ 'position',
+ 'rounded',
+ 'showResizableBorder',
+ 'spaceBetween',
+ 'stack',
+ 'transparent',
+ 'width'
+ ),
+})`
width: ${({ width }) => width || undefined};
min-width: ${({ width }) => (width ? undefined : '160px')};
padding: ${({ padded = true, _size }) =>
diff --git a/src/components/Navbar/divider.tsx b/src/components/Navbar/divider.tsx
index 0fa0afb3..48f3c414 100644
--- a/src/components/Navbar/divider.tsx
+++ b/src/components/Navbar/divider.tsx
@@ -1,6 +1,7 @@
import React, { forwardRef } from 'react';
import styled from 'styled-components';
import { IReqoreTheme } from '../../constants/theme';
+import { omitStyleProps } from '../../helpers/styled';
import { getReadableColor } from '../../helpers/colors';
export interface IReqoreNavbarDividerProps extends React.HTMLAttributes {
@@ -8,7 +9,10 @@ export interface IReqoreNavbarDividerProps extends React.HTMLAttributes`
diff --git a/src/components/Navbar/group.tsx b/src/components/Navbar/group.tsx
index e3ab0c52..b1fb9cda 100644
--- a/src/components/Navbar/group.tsx
+++ b/src/components/Navbar/group.tsx
@@ -1,6 +1,7 @@
import React, { forwardRef } from 'react';
import styled, { css } from 'styled-components';
import { IReqoreTheme } from '../../constants/theme';
+import { omitStyleProps } from '../../helpers/styled';
export interface IReqoreNavbarGroupProps extends React.HTMLAttributes {
position?: 'right' | 'left';
@@ -13,7 +14,10 @@ export interface IReqoreNavbarGroupStyle extends IReqoreNavbarGroupProps {
theme: IReqoreTheme;
}
-export const StyledNavbarGroup = styled.div`
+// The navbar's `type` (`header` / `footer`) picks its theme; it is not the element's `type`.
+export const StyledNavbarGroup = styled.div.withConfig({
+ shouldForwardProp: omitStyleProps('type'),
+})`
${({ position }: IReqoreNavbarGroupStyle) => css`
height: 100%;
float: ${position};
diff --git a/src/components/Navbar/index.tsx b/src/components/Navbar/index.tsx
index 414fa610..08307d81 100644
--- a/src/components/Navbar/index.tsx
+++ b/src/components/Navbar/index.tsx
@@ -3,6 +3,7 @@ import React, { forwardRef } from 'react';
import styled, { css } from 'styled-components';
import { IReqoreNavbarTheme, IReqoreTheme } from '../../constants/theme';
import { getMainColor, getReadableColor } from '../../helpers/colors';
+import { omitStyleProps } from '../../helpers/styled';
import { useReqoreTheme } from '../../hooks/useTheme';
import { IWithReqoreEffect } from '../../types/global';
import { IReqoreEffect, StyledEffect } from '../Effect';
@@ -22,7 +23,10 @@ export interface IReqoreNavbarStyle extends IReqoreNavbarProps {
effect?: IReqoreEffect;
}
-export const StyledNavbar = styled(StyledEffect)`
+// The navbar's `type` (`header` / `footer`) picks its theme; it is not the element's `type`.
+export const StyledNavbar = styled(StyledEffect).withConfig({
+ shouldForwardProp: omitStyleProps('type'),
+})`
${({ theme, type }: IReqoreNavbarStyle) => css`
height: 50px;
flex-shrink: 0;
diff --git a/src/components/Navbar/item.tsx b/src/components/Navbar/item.tsx
index 523f4bff..0e049c52 100644
--- a/src/components/Navbar/item.tsx
+++ b/src/components/Navbar/item.tsx
@@ -1,6 +1,7 @@
import React, { forwardRef } from 'react';
import styled, { css } from 'styled-components';
import { IReqoreTheme } from '../../constants/theme';
+import { omitStyleProps } from '../../helpers/styled';
import { changeLightness, getMainColor } from '../../helpers/colors';
import {
ActiveIconScale,
@@ -22,7 +23,10 @@ export interface IReqoreNavbarItemStyle extends IReqoreNavbarItemProps {
theme?: IReqoreTheme;
}
-const StyledNavbarItem = styled.div`
+// The navbar's `type` (`header` / `footer`) picks its theme; it is not the element's `type`.
+const StyledNavbarItem = styled.div.withConfig({
+ shouldForwardProp: omitStyleProps('type'),
+})`
height: 100%;
min-width: 50px;
display: flex;
diff --git a/src/components/Notifications/styles.tsx b/src/components/Notifications/styles.tsx
index 51065a0e..0b536352 100644
--- a/src/components/Notifications/styles.tsx
+++ b/src/components/Notifications/styles.tsx
@@ -60,6 +60,9 @@ export const StyledReqoreNotification = styled(StyledEffect).withConfig({
'backgroundBlur',
'blur',
'clickable',
+ // Handed to popover content.
+ 'closePopover',
+ 'customTheme',
'fill',
'fixed',
'flat',
@@ -70,9 +73,11 @@ export const StyledReqoreNotification = styled(StyledEffect).withConfig({
'minimal',
'opaque',
'raised',
+ 'size',
'spaceBetween',
'stack',
- 'timeout'
+ 'timeout',
+ 'tooltip'
),
})`
min-width: ${({ fluid }) => (!fluid ? '30px' : undefined)};
diff --git a/src/components/Panel/index.tsx b/src/components/Panel/index.tsx
index d4744f1b..1beeb9bb 100644
--- a/src/components/Panel/index.tsx
+++ b/src/components/Panel/index.tsx
@@ -582,7 +582,18 @@ export const StyledPanel: TPanelStyle = styled(StyledEffect).withConfig({
// Filtering here still leaves the styled-component's own interpolations reading the prop.
// Everything else follows styled-components' own rule, so re-resizable still receives its
// `enable` / size / handle config — those are component props, not HTML attributes.
- shouldForwardProp: omitStyleProps('fill', 'accentPosition'),
+ // `opacity` is the surface's background opacity, an SVG attribute a `div` would keep; the
+ // remaining flags only style the surface, and re-resizable would write them onto its div.
+ shouldForwardProp: omitStyleProps(
+ 'accentPosition',
+ 'fill',
+ 'flat',
+ 'fluid',
+ 'interactive',
+ 'isCollapsed',
+ 'opacity',
+ 'rounded'
+ ),
})`
background-color: ${({ theme, opacity = 1 }: IStyledPanel) =>
rgba(changeDarkness(getMainBackgroundColor(theme), 0.03), opacity)};
@@ -764,7 +775,9 @@ export const StyledPanel: TPanelStyle = styled(StyledEffect).withConfig({
${({ disabled }) => disabled && DisabledElement}
`;
-export const StyledPanelTitle = styled.div`
+export const StyledPanelTitle = styled.div.withConfig({
+ shouldForwardProp: omitStyleProps('opacity'),
+})`
display: flex;
/* Always a row. The title bar used to flip to a column when narrow; it now ellipsizes its
label and lets the responsive action group collapse instead, which is what the "hidden when
@@ -1324,6 +1337,11 @@ export const ReqorePanel = forwardRef(
[cancelHover]
);
+ /* The panel renders as re-resizable's `Resizable` when it is resizable — unless the caller
+ gave its own element in `as`, which always wins. (`rest.as || cond ? Resizable : 'div'`
+ binds as `(rest.as || cond) ? …`, so a panel given `as` used to render a `Resizable`.) */
+ const isResizableElement = !rest.as && !!resizable && !disabled && !_isCollapsed;
+
const _resizable: ResizableProps = useMemo(() => {
// Only carry re-resizable's props when the panel actually renders as a
// `Resizable` (the same condition the `as` below uses). Otherwise the
@@ -1331,9 +1349,8 @@ export const ReqorePanel = forwardRef(
// through the panel — spreading `enable` / `defaultSize` / … onto a div
// would emit invalid-DOM-attribute warnings. A non-resizable (or
// collapsed / disabled) panel therefore carries no resizable props.
- const isResizableElement = !!resizable && !disabled && !_isCollapsed;
return isResizableElement && resizable ? resizable : {};
- }, [resizable, _isCollapsed, disabled]);
+ }, [resizable, isResizableElement]);
// Return true if the card has a title bar, otherwise return false.
const hasTitleBar: boolean = useMemo(
@@ -1855,7 +1872,7 @@ export const ReqorePanel = forwardRef(
diff --git a/src/components/Slider/index.tsx b/src/components/Slider/index.tsx
index ecffe7f8..cbf425ff 100644
--- a/src/components/Slider/index.tsx
+++ b/src/components/Slider/index.tsx
@@ -4,6 +4,7 @@ import styled, { css } from 'styled-components';
import { HALF_PADDING_FROM_SIZE, ICON_FROM_SIZE, TSizes } from '../../constants/sizes';
import { IReqoreCustomTheme, TReqoreIntent } from '../../constants/theme';
import { changeLightness, getReadableColor } from '../../helpers/colors';
+import { omitStyleProps, REQORE_CONTROL_GROUP_CHILD_PROPS } from '../../helpers/styled';
import { useReqoreTheme } from '../../hooks/useTheme';
import { TReqoreTooltipProp } from '../../types/global';
import { IReqoreIconName } from '../../types/icons';
@@ -84,7 +85,11 @@ const StyledThumb = styled(Slider.Thumb)`
outline: none;
}
`;
-const StyledRoot = styled(Slider.Root)`
+// A child of `SliderRootWrapper` (a control group), so it is handed the group's child props;
+// radix writes whatever it is given onto its `span`.
+const StyledRoot = styled(Slider.Root).withConfig({
+ shouldForwardProp: omitStyleProps(...REQORE_CONTROL_GROUP_CHILD_PROPS),
+})`
position: relative;
display: inline-flex;
align-items: center;
diff --git a/src/components/Spacer/index.tsx b/src/components/Spacer/index.tsx
index 91664643..b5dc0d9e 100644
--- a/src/components/Spacer/index.tsx
+++ b/src/components/Spacer/index.tsx
@@ -8,12 +8,17 @@ import {
TSizes,
} from '../../constants/sizes';
import { changeLightness, getReadableColor } from '../../helpers/colors';
+import { omitStyleProps } from '../../helpers/styled';
import { useReqoreTheme } from '../../hooks/useTheme';
import { IReqoreIntent, IWithReqoreCustomTheme, IWithReqoreEffect } from '../../types/global';
import { IReqoreEffect, StyledEffect, TReqoreHexColor } from '../Effect';
import { ReqoreSpan } from '../Span';
-export const StyledSpacer = styled.div`
+// `width` and `height` size the spacer through its styles; as attributes they mean nothing on a
+// `div` (and React warns when a computed one is `NaN`).
+export const StyledSpacer = styled.div.withConfig({
+ shouldForwardProp: omitStyleProps('width', 'height'),
+})`
display: ${({ horizontal }) => (horizontal ? 'inline-flex' : 'flex')};
vertical-align: middle;
align-items: ${({ align }) => {
@@ -40,7 +45,9 @@ export const StyledSpacer = styled.div`
flex-shrink: 0;
`;
-export const StyledSpace = styled.div`
+export const StyledSpace = styled.div.withConfig({
+ shouldForwardProp: omitStyleProps('width', 'height'),
+})`
display: inline-block;
flex: 0 0 auto;
@@ -61,7 +68,9 @@ export const StyledSpace = styled.div`
}}
`;
-export const StyledLine = styled(StyledEffect)`
+export const StyledLine = styled(StyledEffect).withConfig({
+ shouldForwardProp: omitStyleProps('width', 'height'),
+})`
flex-shrink: 0;
background-color: ${({ theme, lineSize }) =>
lineSize === 'none' ? 'transparent' : changeLightness(theme.main, 0.2)};
diff --git a/src/components/Span/index.tsx b/src/components/Span/index.tsx
index 8d641349..c66a7382 100644
--- a/src/components/Span/index.tsx
+++ b/src/components/Span/index.tsx
@@ -1,6 +1,7 @@
import { forwardRef, memo } from 'react';
import styled from 'styled-components';
import { TEXT_FROM_SIZE, TSizes } from '../../constants/sizes';
+import { omitStyleProps } from '../../helpers/styled';
import { isStringSize } from '../../helpers/utils';
import { useReqoreTheme } from '../../hooks/useTheme';
import {
@@ -41,7 +42,11 @@ export interface IReqoreSpanProps
maxWidth?: string;
}
-export const StyledSpan = styled(StyledTextEffect)`
+// Styling props of the span (and of `ReqoreLink`, built on it). A tag drops them on its own; a
+// component given as `as` — a router link — would receive them and write them onto its ``.
+export const StyledSpan = styled(StyledTextEffect).withConfig({
+ shouldForwardProp: omitStyleProps('_size', 'inline', 'maxWidth'),
+})`
color: ${({ theme, intent }) =>
intent ? theme.intents[intent] : theme.text?.color || 'inherit'};
font-size: ${({ _size }) => (isStringSize(_size) ? `${TEXT_FROM_SIZE[_size]}px` : _size)};
diff --git a/src/components/Table/cell.tsx b/src/components/Table/cell.tsx
index 87814c44..c2476a44 100644
--- a/src/components/Table/cell.tsx
+++ b/src/components/Table/cell.tsx
@@ -30,8 +30,9 @@ export interface IReqoreTableBodyCellProps
}
export const StyledTableCell = styled.div.withConfig({
- // `wrap` drives the cell's white-space rule; it is not a DOM attribute.
- shouldForwardProp: omitStyleProps('wrap'),
+ // `wrap` drives the cell's white-space rule and `width` its CSS width; neither is a DOM
+ // attribute of a `div`.
+ shouldForwardProp: omitStyleProps('wrap', 'width'),
})`
${({ width, minWidth, maxWidth, grow }) =>
css`
diff --git a/src/components/Table/header.tsx b/src/components/Table/header.tsx
index 0b3b9960..9e3f1d6a 100644
--- a/src/components/Table/header.tsx
+++ b/src/components/Table/header.tsx
@@ -5,6 +5,7 @@ import { IReqoreTableColumn, IReqoreTableSort } from '.';
import { SIZE_TO_PX, TSizes } from '../../constants/sizes';
import { IReqoreTheme } from '../../constants/theme';
import { changeLightness } from '../../helpers/colors';
+import { omitStyleProps } from '../../helpers/styled';
import { alignToFlexAlign } from '../../helpers/utils';
import { useCombinedRefs } from '../../hooks/useCombinedRefs';
import { IWithReqoreSize } from '../../types/global';
@@ -130,7 +131,9 @@ export const StyledColumnGroupHeader = styled.div`
`}
`;
-const StyledColumnGroup = styled.div`
+const StyledColumnGroup = styled.div.withConfig({
+ shouldForwardProp: omitStyleProps('width'),
+})`
display: flex;
flex-flow: column;
flex-shrink: 0;
diff --git a/src/components/Tabs/index.tsx b/src/components/Tabs/index.tsx
index 418233a8..f1b85bb2 100644
--- a/src/components/Tabs/index.tsx
+++ b/src/components/Tabs/index.tsx
@@ -4,6 +4,7 @@ import styled, { css } from 'styled-components';
import { ReqoreErrorBoundary } from '../..';
import { TSizes } from '../../constants/sizes';
import { IReqoreCustomTheme, TReqoreIntent } from '../../constants/theme';
+import { omitStyleProps } from '../../helpers/styled';
import { IReqoreComponent, IWithReqoreLoading } from '../../types/global';
import { IReqoreIconName } from '../../types/icons';
import { IReqoreButtonProps } from '../Button';
@@ -99,7 +100,9 @@ export interface IReqoreTabsProps extends IReqoreComponent, React.HTMLAttributes
_testWidth?: number;
}
-const StyledTabs = styled.div>`
+const StyledTabs = styled.div.withConfig({
+ shouldForwardProp: omitStyleProps('width'),
+})>`
display: flex;
${({ vertical, fillParent, width }) => css`
width: ${width ? `${width}px` : '100%'};
diff --git a/src/components/Tabs/item.tsx b/src/components/Tabs/item.tsx
index 07bf2a08..507f4ea4 100644
--- a/src/components/Tabs/item.tsx
+++ b/src/components/Tabs/item.tsx
@@ -11,6 +11,7 @@ import {
getMainBackgroundColor,
getReadableColor,
} from '../../helpers/colors';
+import { omitStyleProps } from '../../helpers/styled';
import { useCombinedRefs } from '../../hooks/useCombinedRefs';
import { useReqoreTheme } from '../../hooks/useTheme';
import { IWithReqoreFlat } from '../../types/global';
@@ -41,7 +42,9 @@ export interface IReqoreTabListItemStyle extends IReqoreTabListItemProps {
activeColor: string;
}
-export const StyledTabListItem = styled.div`
+export const StyledTabListItem = styled.div.withConfig({
+ shouldForwardProp: omitStyleProps('fill'),
+})`
${({
theme,
disabled,
diff --git a/src/components/Tabs/list.tsx b/src/components/Tabs/list.tsx
index b649fb6a..a27ad144 100644
--- a/src/components/Tabs/list.tsx
+++ b/src/components/Tabs/list.tsx
@@ -19,6 +19,7 @@ import {
getMainBackgroundColor,
getNthGradientColor,
} from '../../helpers/colors';
+import { omitStyleProps } from '../../helpers/styled';
import { calculateStringSizeInPixels, getOneLessSize } from '../../helpers/utils';
import { useReqoreTheme } from '../../hooks/useTheme';
import { IReqoreBreadcrumbItem } from '../Breadcrumbs';
@@ -42,7 +43,9 @@ export interface IReqoreTabsListStyle extends Omit
theme: IReqoreTheme;
}
-export const StyledReqoreTabsList = styled.div`
+export const StyledReqoreTabsList = styled.div.withConfig({
+ shouldForwardProp: omitStyleProps('fill', 'width'),
+})`
${({ theme, fill, vertical, size, padded, flat, activeTabMarker, currentTabColor, width }) => css`
height: ${vertical ? '100%' : undefined};
width: ${vertical ? width || '200px' : '100%'};
diff --git a/src/components/Tag/index.tsx b/src/components/Tag/index.tsx
index 5d18d413..93bc4da7 100644
--- a/src/components/Tag/index.tsx
+++ b/src/components/Tag/index.tsx
@@ -31,6 +31,7 @@ import {
getReadableColorFrom,
isAchromatic,
} from '../../helpers/colors';
+import { omitStyleProps } from '../../helpers/styled';
import { ActiveIconScale, InactiveIconScale, RaisedElement, ReadOnlyElement } from '../../styles';
import {
IReqoreDisabled,
@@ -291,7 +292,11 @@ export interface IReqoreTagStyle extends IReqoreTagProps {
$appearanceColors?: IReqoreTagAppearanceColors;
}
-export const StyledTag = styled(StyledEffect)`
+// `fill` is handed down by a containing `ReqoreControlGroup`; it is an SVG attribute, so a
+// `span` would keep it. `width` sizes the tag through its styles.
+export const StyledTag = styled(StyledEffect).withConfig({
+ shouldForwardProp: omitStyleProps('fill', 'width'),
+})`
display: inline-flex;
justify-content: center;
flex-shrink: 0;
@@ -696,7 +701,9 @@ const splitTagLabel = (label: string): [string, string] => {
];
};
-const StyledButtonWrapper = styled.span`
+const StyledButtonWrapper = styled.span.withConfig({
+ shouldForwardProp: omitStyleProps('color'),
+})`
flex-shrink: 0;
font-size: ${({ size }) => CONTROL_TEXT_FROM_SIZE[size]}px;
width: ${({ size }) => BADGE_SIZE_TO_PX[size]}px;
diff --git a/src/components/Tier/index.tsx b/src/components/Tier/index.tsx
index 83bcea45..6edf962c 100644
--- a/src/components/Tier/index.tsx
+++ b/src/components/Tier/index.tsx
@@ -95,7 +95,9 @@ export const ReqoreTier = memo(
{badge && (
<>
-
+ {/* On a line of its own, like a callout's or an entity row's badge: nothing to
+ space it from. Sized with the tier's panel. */}
+
>
)}
diff --git a/src/components/Timeline/index.tsx b/src/components/Timeline/index.tsx
index ded491b7..7b983d74 100644
--- a/src/components/Timeline/index.tsx
+++ b/src/components/Timeline/index.tsx
@@ -11,6 +11,7 @@ import {
} from '../../constants/sizes';
import { IReqoreTheme } from '../../constants/theme';
import { changeLightness, getReadableColor } from '../../helpers/colors';
+import { omitStyleProps } from '../../helpers/styled';
import { getOneLessSize } from '../../helpers/utils';
import { useReqoreProperty } from '../../hooks/useReqoreContext';
import { useReqoreTheme } from '../../hooks/useTheme';
@@ -205,7 +206,11 @@ const LINE_WIDTH_FROM_SIZE: Record = {
massive: 3,
};
-const StyledTimeline = styled.ol`
+// `direction` and `spacing` lay the timeline out; both are SVG attributes, so the elements
+// would otherwise keep them.
+const StyledTimeline = styled.ol.withConfig({
+ shouldForwardProp: omitStyleProps('direction', 'spacing'),
+})`
list-style: none;
margin: 0;
padding: 0;
@@ -220,7 +225,9 @@ const StyledTimeline = styled.ol`
`}
`;
-const StyledTimelineItem = styled.li`
+const StyledTimelineItem = styled.li.withConfig({
+ shouldForwardProp: omitStyleProps('direction', 'spacing'),
+})`
display: flex;
position: relative;
@@ -286,7 +293,9 @@ const StyledTimelineItem = styled.li`
`}
`;
-const StyledTimelineMarkerWrapper = styled.div`
+const StyledTimelineMarkerWrapper = styled.div.withConfig({
+ shouldForwardProp: omitStyleProps('direction', 'spacing'),
+})`
display: flex;
flex-direction: column;
align-items: center;
@@ -297,7 +306,9 @@ const StyledTimelineMarkerWrapper = styled.div`
position: relative;
`;
-const StyledTimelineMarker = styled.div`
+const StyledTimelineMarker = styled.div.withConfig({
+ shouldForwardProp: omitStyleProps('direction', 'spacing'),
+})`
display: flex;
align-items: center;
justify-content: center;
@@ -324,7 +335,9 @@ const StyledTimelineDot = styled.div`
// Line now positioned to connect markers (icon to icon) — vertical mode only.
// Horizontal mode draws its connector via a pseudo-element on StyledTimelineItem.
-const StyledTimelineLine = styled.div`
+const StyledTimelineLine = styled.div.withConfig({
+ shouldForwardProp: omitStyleProps('direction', 'spacing'),
+})`
position: absolute;
left: 50%;
transform: translateX(-50%);
@@ -355,7 +368,9 @@ const StyledTimelineLine = styled.div`
`}
`;
-const StyledTimelineContent = styled.div`
+const StyledTimelineContent = styled.div.withConfig({
+ shouldForwardProp: omitStyleProps('direction', 'spacing'),
+})`
flex: 1;
min-width: 0;
${({ direction, size }) =>
diff --git a/src/components/TooltipComponent/index.tsx b/src/components/TooltipComponent/index.tsx
index a9991654..fd466132 100644
--- a/src/components/TooltipComponent/index.tsx
+++ b/src/components/TooltipComponent/index.tsx
@@ -1,3 +1,4 @@
+import { omit } from 'lodash';
import { forwardRef, memo } from 'react';
import { buildTooltipForComponents } from '../../helpers/utils';
import { TReqoreTooltipProp } from '../../types/global';
@@ -21,7 +22,9 @@ export const ReqoreTooltipComponent = memo(
component={Component}
isReqoreComponent
ref={ref}
- componentProps={rest}
+ // The popover draws the tooltip; a DOM element (`Component='div'`) has no use for it
+ // and would render it as a `tooltip` attribute.
+ componentProps={typeof Component === 'string' ? omit(rest, ['tooltip']) : rest}
>
{rest.children}
diff --git a/src/components/Tree/index.tsx b/src/components/Tree/index.tsx
index 31cf82b7..7efba679 100644
--- a/src/components/Tree/index.tsx
+++ b/src/components/Tree/index.tsx
@@ -162,6 +162,8 @@ export const ReqoreTree = ({
dialogValueLabel = 'Value',
dialogKeyPlaceholder = 'Key',
dialogValuePlaceholder = 'Value',
+ // Not used by the tree any more; taken out so it does not reach the panel's element.
+ mode: _mode,
...rest
}: IReqoreTreeProps) => {
const [items, setItems] = useState({});
diff --git a/src/helpers/styled.ts b/src/helpers/styled.ts
index a3b4ce88..37842f61 100644
--- a/src/helpers/styled.ts
+++ b/src/helpers/styled.ts
@@ -56,3 +56,38 @@ export const omitStyleProps = (...propsToOmit: string[]) => {
return typeof elementToBeCreated === 'string' ? defaultValidatorFn(prop) : true;
};
};
+
+/**
+ * The props `ReqoreControlGroup` clones onto every child that is a component (see the group's
+ * `useCloneThroughFragments` callback): its layout flags, and — in a `stack` group — where the
+ * child sits in the stack, for nested groups to round their corners by. A child that renders a
+ * DOM element, or a third-party component that writes its props onto one, must not forward
+ * them: `shouldForwardProp: omitStyleProps(...REQORE_CONTROL_GROUP_CHILD_PROPS)`.
+ */
+export const REQORE_CONTROL_GROUP_CHILD_PROPS = [
+ 'customTheme',
+ 'fill',
+ 'fixed',
+ 'flat',
+ 'fluid',
+ 'intent',
+ 'minimal',
+ 'size',
+ 'spaceBetween',
+ 'stack',
+ // Stack groups only.
+ 'childId',
+ 'childrenCount',
+ 'isChild',
+ 'isFirst',
+ 'isFirstGroup',
+ 'isFirstInLastGroup',
+ 'isInsideStackGroup',
+ 'isInsideVerticalGroup',
+ 'isLast',
+ 'isLastGroup',
+ 'isLastInFirstGroup',
+ 'isLastInLastGroup',
+ 'isMasterGroupRounded',
+ 'rounded',
+];
diff --git a/src/styles.ts b/src/styles.ts
index 0c12fcdb..0b97874d 100644
--- a/src/styles.ts
+++ b/src/styles.ts
@@ -1,11 +1,15 @@
import styled, { css } from 'styled-components';
import { StyledTextEffect } from './components/Effect';
import { StyledIconWrapper } from './components/Icon';
+import { omitStyleProps } from './helpers/styled';
export const INACTIVE_ICON_SCALE = 0.85;
export const ACTIVE_ICON_SCALE = 0.93;
-export const StyledContent = styled(StyledTextEffect)`
+// `wrap` sets how a label breaks; on a `span` it is not an attribute.
+export const StyledContent = styled(StyledTextEffect).withConfig({
+ shouldForwardProp: omitStyleProps('wrap'),
+})`
position: relative;
${({ wrap }) =>
@@ -21,7 +25,9 @@ export const StyledContent = styled(StyledTextEffect)`
`}
`;
-export const StyledActiveContent = styled(StyledTextEffect)`
+export const StyledActiveContent = styled(StyledTextEffect).withConfig({
+ shouldForwardProp: omitStyleProps('wrap'),
+})`
position: absolute;
transform: translateY(-150%);
opacity: 0;
@@ -41,7 +47,9 @@ export const StyledActiveContent = styled(StyledTextEffect)`
`}
`;
-export const StyledInActiveContent = styled(StyledTextEffect)`
+export const StyledInActiveContent = styled(StyledTextEffect).withConfig({
+ shouldForwardProp: omitStyleProps('wrap'),
+})`
position: absolute;
transform: translateY(0);
transition: all 0.2s ease-out;
@@ -59,7 +67,9 @@ export const StyledInActiveContent = styled(StyledTextEffect)`
`}
`;
-export const StyledInvisibleContent = styled(StyledTextEffect)`
+export const StyledInvisibleContent = styled(StyledTextEffect).withConfig({
+ shouldForwardProp: omitStyleProps('wrap'),
+})`
visibility: hidden;
position: relative;
overflow: hidden;