diff --git a/packages/@react-spectrum/s2/chromatic/LabeledValue.stories.tsx b/packages/@react-spectrum/s2/chromatic/LabeledValue.stories.tsx new file mode 100644 index 00000000000..d911b59fa99 --- /dev/null +++ b/packages/@react-spectrum/s2/chromatic/LabeledValue.stories.tsx @@ -0,0 +1,47 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {LabeledValue} from '../src/LabeledValue'; +import type {Meta} from '@storybook/react'; + +const meta: Meta = { + component: LabeledValue, + parameters: { + chromaticProvider: {disableAnimations: true} + }, + title: 'S2 Chromatic/LabeledValue' +}; + +export default meta; + +export { + Default, + LongText, + StringArray, + CalendarDateType, + CalendarDateTimeType, + CalendarDateTimeTypeFormatOptions, + ZonedDateTimeType, + DateType, + TimeType, + CalendarDateRange, + CalendarDateTimeRange, + ZonedDateTimeRange, + DateRange, + TimeRange, + Number, + NumberRange, + CustomComponents, + WithContextualHelp, + FormCustomLayoutExample, + FormLayout +} from '../stories/LabeledValue.stories'; diff --git a/packages/@react-spectrum/s2/exports/LabeledValue.ts b/packages/@react-spectrum/s2/exports/LabeledValue.ts new file mode 100644 index 00000000000..514f51f4e21 --- /dev/null +++ b/packages/@react-spectrum/s2/exports/LabeledValue.ts @@ -0,0 +1,2 @@ +export {LabeledValue, LabeledValueContext} from '../src/LabeledValue'; +export type {LabeledValueProps} from '../src/LabeledValue'; diff --git a/packages/@react-spectrum/s2/exports/index.ts b/packages/@react-spectrum/s2/exports/index.ts index 92cfa26bf27..8b21f10ddbc 100644 --- a/packages/@react-spectrum/s2/exports/index.ts +++ b/packages/@react-spectrum/s2/exports/index.ts @@ -55,6 +55,7 @@ export {IllustratedMessage, IllustratedMessageContext} from '../src/IllustratedM export {Image, ImageContext} from '../src/Image'; export {ImageCoordinator} from '../src/ImageCoordinator'; export {InlineAlert, InlineAlertContext} from '../src/InlineAlert'; +export {LabeledValue, LabeledValueContext} from '../src/LabeledValue'; export {Link, LinkContext} from '../src/Link'; export {ListView, ListViewContext, ListViewItem} from '../src/ListView'; export {MenuItem, MenuTrigger, Menu, MenuSection, SubmenuTrigger, UnavailableMenuItemTrigger, MenuContext} from '../src/Menu'; @@ -143,6 +144,7 @@ export type {IconProps, IconContextValue, IllustrationProps, IllustrationContext export type {InlineAlertProps} from '../src/InlineAlert'; export type {ImageProps} from '../src/Image'; export type {ImageCoordinatorProps} from '../src/ImageCoordinator'; +export type {LabeledValueProps} from '../src/LabeledValue'; export type {LinkProps} from '../src/Link'; export type {ListViewProps, ListViewItemProps} from '../src/ListView'; export type {MenuTriggerProps, MenuProps, MenuItemProps, MenuSectionProps, SubmenuTriggerProps, UnavailableMenuItemTriggerProps} from '../src/Menu'; diff --git a/packages/@react-spectrum/s2/src/LabeledValue.tsx b/packages/@react-spectrum/s2/src/LabeledValue.tsx new file mode 100644 index 00000000000..a413b720709 --- /dev/null +++ b/packages/@react-spectrum/s2/src/LabeledValue.tsx @@ -0,0 +1,235 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {CalendarDate, CalendarDateTime, getLocalTimeZone, Time, toCalendarDateTime, today, ZonedDateTime} from '@internationalized/date'; +import {ContextValue} from 'react-aria-components/slots'; +import {controlFont, controlSize, field, fieldInput, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; +import {createContext, forwardRef, isValidElement, ReactElement, ReactNode} from 'react'; +import {DOMProps, DOMRef, DOMRefValue, RangeValue, SpectrumLabelableProps} from '@react-types/shared'; +import {FieldLabel} from './Field'; +import {filterDOMProps} from 'react-aria/filterDOMProps'; +import {FormContext, useFormProps} from './Form'; +import {style} from '../style' with {type: 'macro'}; +import {useContext, useEffect} from 'react'; +import {useDateFormatter} from 'react-aria/useDateFormatter'; +import {useDOMRef} from './useDOMRef'; +import {useListFormatter} from 'react-aria/useListFormatter'; +import {useNumberFormatter} from 'react-aria/useNumberFormatter'; +import {useSpectrumContextProps} from './useSpectrumContextProps'; + +type NumberValue = number | RangeValue; +interface NumberProps { + /** The value to display. */ + value: T, + /** Formatting options for the value. */ + formatOptions?: Intl.NumberFormatOptions +} + +export type DateTime = Date | CalendarDate | CalendarDateTime | ZonedDateTime | Time; +type RangeDateTime = RangeValue; +type DateTimeValue = DateTime | RangeDateTime; +interface DateProps { + /** The value to display. */ + value: T, + /** Formatting options for the value. */ + formatOptions?: Intl.DateTimeFormatOptions +} + +interface StringProps { + /** The value to display. */ + value: T, + /** Formatting options for the value. */ + formatOptions?: never +} + +interface StringListProps { + /** The value to display. */ + value: T, + /** Formatting options for the value. */ + formatOptions?: Intl.ListFormatOptions +} + +interface ReactElementProps { + /** The value to display. */ + value: T, + /** Formatting options for the value. */ + formatOptions?: never +} + +export interface LabeledValueStyleProps { + /** + * The size of the component. + * @default 'M' + */ + size?: 'S' | 'M' | 'L' | 'XL' +} +export interface LabeledValueBaseProps extends DOMProps, StyleProps, Omit { + /** The content to display as the label. */ + label: ReactNode +} +type LabeledValueTypeProps = + T extends NumberValue ? NumberProps : + T extends DateTimeValue ? DateProps : + T extends string[] ? StringListProps : + T extends string ? StringProps : + T extends ReactElement ? ReactElementProps : + never; + +type LabeledValueTypes = string[] | string | Date | CalendarDate | CalendarDateTime | ZonedDateTime | Time | number | RangeValue | RangeValue | ReactElement; +export type LabeledValueProps = LabeledValueTypeProps & LabeledValueBaseProps & LabeledValueStyleProps; + +export const LabeledValueContext = createContext>, DOMRefValue>>(null); + +const fieldStyles = style({ + ...field() +}, getAllowedOverrides()); + +const valueStyles = style({ + ...fieldInput(), + minHeight: { + isInForm: controlSize() + }, + display: 'flex', + alignItems: 'center', + font: controlFont() +}); + +/** + * A LabeledValue displays a non-editable value with a label. It formats numbers, + * dates, times, and lists according to the user's locale. + */ +export const LabeledValue = /*#__PURE__*/ forwardRef(function LabeledValue(props: LabeledValueProps, ref: DOMRef) { + [props, ref] = useSpectrumContextProps(props as any, ref, LabeledValueContext) as any; + props = useFormProps(props); + let { + label, + value, + formatOptions, + size = 'M', + labelPosition = 'top', + labelAlign = 'start', + contextualHelp, + UNSAFE_className = '', + UNSAFE_style, + styles, + ...otherProps + } = props; + let formContext = useContext(FormContext); + + 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: ReactNode; + + if (Array.isArray(value)) { + children = ; + } else if (typeof value === 'number') { + children = ; + } else if (typeof value === 'object' && value !== null && 'start' in value && typeof (value as RangeValue).start === 'number') { + children = } formatOptions={formatOptions as Intl.NumberFormatOptions} />; + } else if (typeof value === 'object' && value !== null && 'start' in value) { + children = ; + } else if (value instanceof Date || (typeof value === 'object' && value !== null && ('calendar' in value || 'hour' in value))) { + children = ; + } else if (typeof value === 'string') { + children = value; + } else if (isValidElement(value)) { + children = value; + } + + return ( +
+ + {label} + + + {children} + +
+ ); +}); + +function FormattedStringList({value, formatOptions}: {value: string[], formatOptions?: Intl.ListFormatOptions}) { + let formatter = useListFormatter(formatOptions ?? {}); + return <>{formatter.format(value)}; +} + +function FormattedNumber({value, formatOptions}: {value: NumberValue, formatOptions?: Intl.NumberFormatOptions}) { + let formatter = useNumberFormatter(formatOptions); + if (typeof value === 'object') { + return <>{formatter.formatRange(value.start, value.end)}; + } + return <>{formatter.format(value)}; +} + +function FormattedDate({value, formatOptions}: {value: T, formatOptions?: Intl.DateTimeFormatOptions}) { + if (!formatOptions) { + formatOptions = getDefaultFormatOptions('start' in value ? (value as RangeDateTime).start : value as DateTime); + } + + let dateFormatter = useDateFormatter(formatOptions); + let timeZone = dateFormatter.resolvedOptions().timeZone || getLocalTimeZone(); + + if ('start' in value && 'end' in value) { + let start = convertDateTime((value as RangeDateTime).start, timeZone); + let end = convertDateTime((value as RangeDateTime).end, timeZone); + return <>{dateFormatter.formatRange(start, end)}; + } + + return <>{dateFormatter.format(convertDateTime(value as DateTime, timeZone))}; +} + +function convertDateTime(value: DateTime, timeZone: string): Date { + if ('timeZone' in value) { + return (value as ZonedDateTime).toDate(); + } else if ('calendar' in value) { + return (value as CalendarDate | CalendarDateTime).toDate(timeZone); + } else if (!(value instanceof Date)) { + return toCalendarDateTime(today(getLocalTimeZone()), value as Time).toDate(timeZone); + } + return value; +} + +function getDefaultFormatOptions(value: DateTime): Intl.DateTimeFormatOptions { + if (value instanceof Date) { + return {dateStyle: 'long', timeStyle: 'short'}; + } else if ('timeZone' in value) { + return {year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric', timeZone: (value as ZonedDateTime).timeZone, timeZoneName: 'short'}; + } else if ('hour' in value && 'year' in value) { + return {dateStyle: 'long', timeStyle: 'short'}; + } else if ('hour' in value) { + return {timeStyle: 'short'}; + } else { + return {dateStyle: 'long'}; + } +} diff --git a/packages/@react-spectrum/s2/stories/LabeledValue.stories.tsx b/packages/@react-spectrum/s2/stories/LabeledValue.stories.tsx new file mode 100644 index 00000000000..f61fcda9d87 --- /dev/null +++ b/packages/@react-spectrum/s2/stories/LabeledValue.stories.tsx @@ -0,0 +1,221 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {Badge} from '../src/Badge'; +import {Button} from '../src/Button'; +import {CalendarDate, CalendarDateTime, Time, ZonedDateTime} from '@internationalized/date'; +import {ComboBox, ComboBoxItem} from '../src/ComboBox'; +import {Content, Heading} from '../src/Content'; +import {ContextualHelp} from '../src/ContextualHelp'; +import {Form} from '../src/Form'; +import {LabeledValue} from '../src/LabeledValue'; +import {Link} from '../src/Link'; +import type {Meta, StoryObj} from '@storybook/react'; +import {NumberField} from '../src/NumberField'; +import {ReactElement} from 'react'; +import {StatusLight} from '../src/StatusLight'; +import {style} from '../style' with {type: 'macro'}; +import {TextField} from '../src/TextField'; + +const meta: Meta = { + component: LabeledValue, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + argTypes: { + label: {control: {type: 'text'}}, + contextualHelp: {table: {disable: true}}, + value: {table: {disable: true}}, + labelPosition: { + control: {type: 'radio'}, + options: ['top', 'side'] + }, + labelAlign: { + control: {type: 'radio'}, + options: ['start', 'end'] + }, + size: { + control: {type: 'radio'}, + options: ['S', 'M', 'L', 'XL'] + } + }, + args: { + label: 'Name' + }, + title: 'LabeledValue' +}; + +export default meta; +type Story = StoryObj; + + +export const Default: Story = { + args: {label: 'Name', value: 'Jane Smith'}, + name: 'String' +}; + +export const LongText: Story = { + args: {label: 'Test', value: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'}, + name: 'Long text' +}; + +export const StringArray: Story = { + args: {label: 'Pets', value: ['Dogs', 'Cats', 'Fish']}, + name: 'String array' +}; + +export const CalendarDateType: Story = { + args: {label: 'Birthday', value: new CalendarDate(2019, 6, 5)}, + name: 'CalendarDate' +}; + +export const CalendarDateTimeType: Story = { + args: {label: 'Meeting Time', value: new CalendarDateTime(2020, 2, 3, 12, 30, 24, 120)}, + name: 'CalendarDateTime' +}; + +export const CalendarDateTimeTypeFormatOptions: Story = { + args: {label: 'Meeting Time', value: new CalendarDateTime(2020, 2, 3, 12, 30, 24, 120), formatOptions: {dateStyle: 'short', timeStyle: 'short'}}, + name: 'CalendarDateTime with formatOptions' +}; + +export const ZonedDateTimeType: Story = { + args: {label: 'Meeting Time', value: new ZonedDateTime(2020, 2, 3, 'America/Los_Angeles', -28800000)}, + name: 'ZonedDateTime' +}; + +export const DateType: Story = { + args: {label: 'Birthday', value: new Date(2000, 5, 5)}, + name: 'Date' +}; + +export const TimeType: Story = { + args: {label: 'Start time', value: new Time(9, 45)}, + name: 'Time' +}; + +export const CalendarDateRange: Story = { + args: {label: 'Vacation', value: {start: new CalendarDate(2019, 6, 5), end: new CalendarDate(2019, 7, 5)}}, + name: 'RangeValue' +}; + +export const CalendarDateTimeRange: Story = { + args: {label: 'Sabbatical', value: {start: new CalendarDateTime(2020, 2, 3, 12, 30, 24, 120), end: new CalendarDateTime(2020, 3, 3, 12, 30, 24, 120)}}, + name: 'RangeValue' +}; + +export const ZonedDateTimeRange: Story = { + args: {label: 'Event Time', value: {start: new ZonedDateTime(2020, 2, 3, 'America/Los_Angeles', -28800000), end: new ZonedDateTime(2020, 3, 3, 'America/Los_Angeles', -28800000)}}, + name: 'RangeValue' +}; + +export const DateRange: Story = { + args: {label: 'Test', value: {start: new Date(2019, 6, 5), end: new Date(2019, 6, 10)}}, + name: 'RangeValue' +}; + +export const TimeRange: Story = { + args: {label: 'Office Hours', value: {start: new Time(9, 45), end: new Time(10, 50)}}, + name: 'RangeValue