Skip to content

Commit

Permalink
feat: Allow ReactElement in LabeledValue value (#7679)
Browse files Browse the repository at this point in the history
* Allow ReactNode as LabeledValue value

* Add useEffect

* Fix linter errors

* Fix error case tests

* fix story

* Remove todo

* Fix error test

* Add docs

* Fix linter

* Remove console.error check

* Fix docs

* Fix lint errors

* Fix lint errors

* Update to use ReactElement

* Update packages/@react-spectrum/labeledvalue/stories/LabeledValue.stories.tsx

* Fix errorMessage test

---------

Co-authored-by: Sana Malik <[email protected]>
Co-authored-by: Reid Barber <[email protected]>
Co-authored-by: Robert Snow <[email protected]>
  • Loading branch information
4 people authored Feb 3, 2025
1 parent 41ef71d commit 2905848
Show file tree
Hide file tree
Showing 5 changed files with 99 additions and 24 deletions.
10 changes: 10 additions & 0 deletions packages/@react-spectrum/labeledvalue/docs/LabeledValue.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,16 @@ By default, the list is displayed as a conjunction (an "and"-based grouping of i
<LabeledValue label="Interests" value={['Travel', 'Hiking', 'Snorkeling', 'Camping']} formatOptions={{type: 'unit'}} />
```

### Components

The value can be a component and will be rendered as provided. Components cannot be editable.

```tsx example
import {Link} from '@adobe/react-spectrum';

<LabeledValue label="Website" value={<Link href="https://www.adobe.com/">Adobe.com</Link>} />
```

## Labeling

A visual label must be provided to the `LabeledValue` using the `label` prop.
Expand Down
3 changes: 2 additions & 1 deletion packages/@react-spectrum/labeledvalue/docs/types.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import {DateTime, LabeledValueBaseProps} from '@react-spectrum/labeledvalue/src/LabeledValue';
import {RangeValue} from '@react-types/shared';
import {ReactElement} from 'react';

// The doc generator is not smart enough to handle the real types for LabeledValue so this is a simpler one.
export interface LabeledValueProps extends LabeledValueBaseProps {
/** The value to display. */
value: string | string[] | number | RangeValue<number> | DateTime | RangeValue<DateTime>,
value: string | string[] | number | RangeValue<number> | DateTime | RangeValue<DateTime> | ReactElement,
/** Formatting options for the value. The available options depend on the type passed to the `value` prop. */
formatOptions?: Intl.NumberFormatOptions | Intl.DateTimeFormatOptions | Intl.ListFormatOptions
}
27 changes: 25 additions & 2 deletions packages/@react-spectrum/labeledvalue/src/LabeledValue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type {DOMProps, DOMRef, RangeValue, SpectrumLabelableProps, StyleProps} f
import {Field} from '@react-spectrum/label';
import {filterDOMProps} from '@react-aria/utils';
import labelStyles from '@adobe/spectrum-css-temp/components/fieldlabel/vars.css';
import React, {ReactNode} from 'react';
import React, {ReactElement, ReactNode, useEffect} from 'react';
import {useDateFormatter, useListFormatter, useNumberFormatter} from '@react-aria/i18n';

// NOTE: the types here need to be synchronized with the ones in docs/types.ts, which are simpler so the documentation generator can handle them.
Expand Down Expand Up @@ -58,14 +58,22 @@ interface StringListProps<T extends string[]> {
formatOptions?: Intl.ListFormatOptions
}

interface ReactElementProps<T extends ReactElement> {
/** The value to display. */
value: T,
/** Formatting options for the value. */
formatOptions?: never
}

type LabeledValueProps<T> =
T extends NumberValue ? NumberProps<T> :
T extends DateTimeValue ? DateProps<T> :
T extends string[] ? StringListProps<T> :
T extends string ? StringProps<T> :
T extends ReactElement ? ReactElementProps<T> :
never;

type SpectrumLabeledValueTypes = string[] | string | Date | CalendarDate | CalendarDateTime | ZonedDateTime | Time | number | RangeValue<number> | RangeValue<DateTime>;
type SpectrumLabeledValueTypes = string[] | string | Date | CalendarDate | CalendarDateTime | ZonedDateTime | Time | number | RangeValue<number> | RangeValue<DateTime> | ReactElement;
export type SpectrumLabeledValueProps<T> = LabeledValueProps<T> & LabeledValueBaseProps;

/**
Expand All @@ -78,6 +86,17 @@ export const LabeledValue = React.forwardRef(function LabeledValue<T extends Spe
} = props;
let domRef = useDOMRef(ref);

useEffect(() => {
if (
domRef?.current &&
domRef.current.querySelectorAll('input, [contenteditable], textarea')
.length > 0
) {
throw new Error('LabeledValue cannot contain an editable value.');
}
}, [domRef]);


let children;
if (Array.isArray(value)) {
children = <FormattedStringList value={value} formatOptions={formatOptions as Intl.ListFormatOptions} />;
Expand All @@ -103,6 +122,10 @@ export const LabeledValue = React.forwardRef(function LabeledValue<T extends Spe
children = value;
}

if (React.isValidElement(value)) {
children = value;
}

return (
<Field {...props as any} wrapperProps={filterDOMProps(props as any)} ref={domRef} elementType="span" wrapperClassName={classNames(labelStyles, 'spectrum-LabeledValue')}>
<span>{children}</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,15 @@
*/

import {CalendarDate, CalendarDateTime, Time, ZonedDateTime} from '@internationalized/date';
import {ComponentMeta, ComponentStoryObj} from '@storybook/react';
import {Content} from '@react-spectrum/view';
import {ContextualHelp} from '@react-spectrum/contextualhelp';
import {Heading} from '@react-spectrum/text';
import {LabeledValue} from '..';
import {Link} from '@react-spectrum/link';
import {Meta} from '@storybook/react';
import React from 'react';

type LabeledValueStory = ComponentStoryObj<typeof LabeledValue>;

export default {
const meta: Meta<typeof LabeledValue> = {
title: 'LabeledValue',
component: LabeledValue,
argTypes: {
Expand All @@ -43,84 +42,95 @@ export default {
}
}
}
} as ComponentMeta<typeof LabeledValue>;
};

export default meta;

export let Default: LabeledValueStory = {
export let Default = {
args: {label: 'Test', value: 'foo '.repeat(20)},
name: 'String'
};

export let StringArray: LabeledValueStory = {
export let StringArray = {
args: {label: 'Test', value: ['wow', 'cool', 'awesome']},
name: 'String array'
};

export let CalendarDateType: LabeledValueStory = {
export let CalendarDateType = {
args: {label: 'Test', value: new CalendarDate(2019, 6, 5)},
name: 'CalendarDate'
};

export let CalendarDateTimeType: LabeledValueStory = {
export let CalendarDateTimeType = {
args: {label: 'Test', value: new CalendarDateTime(2020, 2, 3, 12, 23, 24, 120)},
name: 'CalendarDateTime'
};

export let CalendarDateTimeTypeFormatOptions: LabeledValueStory = {
export let CalendarDateTimeTypeFormatOptions = {
args: {label: 'Test', value: new CalendarDateTime(2020, 2, 3, 12, 23, 24, 120), formatOptions: {dateStyle: 'short', timeStyle: 'short'}},
name: 'CalendarDateTime with formatOptions'
};

export let ZonedDateTimeType: LabeledValueStory = {
export let ZonedDateTimeType = {
args: {label: 'Test', value: new ZonedDateTime(2020, 2, 3, 'America/Los_Angeles', -28800000)},
name: 'ZonedDateTime'
};

export let DateType: LabeledValueStory = {
export let DateType = {
args: {label: 'Test', value: new Date(2000, 5, 5)},
name: 'Date'
};

export let TimeType: LabeledValueStory = {
export let TimeType = {
args: {label: 'Test', value: new Time(9, 45)},
name: 'Time'
};

export let CalendarDateRange: LabeledValueStory = {
export let CalendarDateRange = {
args: {label: 'Test', value: {start: new CalendarDate(2019, 6, 5), end: new CalendarDate(2019, 7, 5)}},
name: 'RangeValue<CalendarDate>'
};

export let CalendarDateTimeRange: LabeledValueStory = {
export let CalendarDateTimeRange = {
args: {label: 'Test', value: {start: new CalendarDateTime(2020, 2, 3, 12, 23, 24, 120), end: new CalendarDateTime(2020, 3, 3, 12, 23, 24, 120)}},
name: 'RangeValue<CalendarDateTime>'
};

export let ZonedDateTimeRange: LabeledValueStory = {
export let ZonedDateTimeRange = {
args: {label: 'Test', value: {start: new ZonedDateTime(2020, 2, 3, 'America/Los_Angeles', -28800000), end: new ZonedDateTime(2020, 3, 3, 'America/Los_Angeles', -28800000)}},
name: 'RangeValue<ZonedDateTime>'
};

export let DateRange: LabeledValueStory = {
export let DateRange = {
args: {label: 'Test', value: {start: new Date(2019, 6, 5), end: new Date(2019, 6, 10)}},
name: 'RangeValue<Date>'
};

export let TimeRange: LabeledValueStory = {
export let TimeRange = {
args: {label: 'Test', value: {start: new Time(9, 45), end: new Time(10, 50)}},
name: 'RangeValue<Time>'
};

export let Number: LabeledValueStory = {
export let Number = {
args: {label: 'Test', value: 10},
name: 'Number'
};

export let NumberRange: LabeledValueStory = {
export let NumberRange = {
args: {label: 'Test', value: {start: 10, end: 20}},
name: 'RangeValue<Number>'
};

export let WithContextualHelp: LabeledValueStory = {

export let CustomComponent = {
args: {
label: 'Test',
value: <Link href="https://www.adobe.com">Adobe</Link>
},
name: 'Custom component'
};

export let WithContextualHelp = {
args: {
label: 'Test',
value: 25,
Expand Down
31 changes: 31 additions & 0 deletions packages/@react-spectrum/labeledvalue/test/LabeledValue.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,37 @@ describe('LabeledValue', function () {
expect(staticField).toHaveTextContent('10 – 20');
});

it('renders correctly with ReactElement value', function () {
let {getByTestId} = render(
<LabeledValue
data-testid="test-id"
label="Field label"
value={<a href="https://test.com">test</a>} />
);

let staticField = getByTestId('test-id');
expect(staticField).toBeInTheDocument();
expect(staticField).toHaveTextContent('test');
expect(
within(staticField).getByRole('link', {name: 'test'})
).toBeInTheDocument();
});

it('throws when an editable value is provided', async function () {
jest.spyOn(console, 'error').mockImplementation(() => {});
let errorMessage;
try {
render(
<LabeledValue
label="Field label"
value={<input />} />
);
} catch (e) {
errorMessage = e.message;
}
expect(errorMessage).toEqual('LabeledValue cannot contain an editable value.');
});

it('attaches a user provided ref to the outer div', function () {
let ref = React.createRef();
let {getByTestId} = render(
Expand Down

1 comment on commit 2905848

@rspbot
Copy link

@rspbot rspbot commented on 2905848 Feb 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.