From 7131ea583bd9672a3fc070ea7f11f4ae65f5aa7a Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:18:21 -0700 Subject: [PATCH 01/14] initialize labeled value component --- packages/@react-spectrum/s2/exports/index.ts | 2 + .../@react-spectrum/s2/src/LabeledValue.tsx | 238 ++++++++++++++++++ .../s2/stories/LabeledValue.stories.tsx | 185 ++++++++++++++ 3 files changed, 425 insertions(+) create mode 100644 packages/@react-spectrum/s2/src/LabeledValue.tsx create mode 100644 packages/@react-spectrum/s2/stories/LabeledValue.stories.tsx diff --git a/packages/@react-spectrum/s2/exports/index.ts b/packages/@react-spectrum/s2/exports/index.ts index 57dbf4fdde1..e595ee7d02a 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 {LabeledValueStyleProps, LabeledValueBaseProps, LabeledValueProps, DateTime as LabeledValueDateTime} 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..a865eee50a4 --- /dev/null +++ b/packages/@react-spectrum/s2/src/LabeledValue.tsx @@ -0,0 +1,238 @@ +/* + * 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 {createContext, forwardRef, isValidElement, ReactElement, ReactNode} from 'react'; +import {ContextValue, SlotProps} from 'react-aria-components/slots'; +import {DOMProps, DOMRef, DOMRefValue, RangeValue, SpectrumLabelableProps} from '@react-types/shared'; +import {field, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; +import {FieldLabel} from './Field'; +import {filterDOMProps} from 'react-aria/filterDOMProps'; +import {mergeStyles} from '../style/runtime'; +import {style} from '../style' with {type: 'macro'}; +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'; + +// NOTE: the value/formatOptions types here need to be synchronized with the ones +// in the docs/types.ts of the @adobe/react-spectrum package. + +export type DateTime = Date | CalendarDate | CalendarDateTime | ZonedDateTime | Time; +type RangeDateTime = RangeValue; +type DateTimeValue = DateTime | RangeDateTime; +type NumberValue = number | RangeValue; + +type SpectrumLabeledValueTypes = string[] | string | Date | CalendarDate | CalendarDateTime | ZonedDateTime | Time | number | RangeValue | RangeValue | ReactElement; + +interface NumberValueProps { + /** The value to display. */ + value: T, + /** Formatting options for the value. */ + formatOptions?: Intl.NumberFormatOptions +} + +interface DateValueProps { + /** The value to display. */ + value: T, + /** Formatting options for the value. */ + formatOptions?: Intl.DateTimeFormatOptions +} + +interface StringValueProps { + /** The value to display. */ + value: T, + /** Formatting options for the value. */ + formatOptions?: never +} + +interface StringListValueProps { + /** The value to display. */ + value: T, + /** Formatting options for the value. */ + formatOptions?: Intl.ListFormatOptions +} + +interface ReactElementValueProps { + /** The value to display. */ + value: T, + /** Formatting options for the value. */ + formatOptions?: never +} + +type LabeledValueValueProps = + T extends NumberValue ? NumberValueProps : + T extends DateTimeValue ? DateValueProps : + T extends string[] ? StringListValueProps : + T extends string ? StringValueProps : + T extends ReactElement ? ReactElementValueProps : + never; + +/** + * Style props for LabeledValue — contains the S2 size scale. + */ +export interface LabeledValueStyleProps { + /** + * The size of the component. + * @default 'M' + */ + size?: 'S' | 'M' | 'L' | 'XL' +} + +/** + * Base props for LabeledValue — mirrors the v3 LabeledValueBaseProps. + * Extends SpectrumLabelableProps but omits necessity indicator props + * since LabeledValue is a read-only display component. + */ +export interface LabeledValueBaseProps extends DOMProps, StyleProps, SlotProps, Omit { + /** The content to display as the label. */ + label: ReactNode +} + +/** + * Combined props interface for LabeledValue. + */ +export interface LabeledValueProps extends LabeledValueStyleProps, LabeledValueBaseProps {} + +export const LabeledValueContext = createContext, DOMRefValue>>(null); + +const labeledValueStyles = style({ + ...field() +}, getAllowedOverrides()); + +const valueStyles = style({ + gridArea: 'input', + color: 'neutral', + minWidth: 0 +}); + +/** + * 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: LabeledValueValueProps & LabeledValueProps, + ref: DOMRef +) { + [props, ref] = useSpectrumContextProps(props as any, ref, LabeledValueContext) as any; + let { + label, + value, + formatOptions, + size = 'M', + labelPosition = 'top', + labelAlign = 'start', + contextualHelp, + UNSAFE_className = '', + UNSAFE_style, + styles, + ...otherProps + } = props as LabeledValueValueProps & LabeledValueProps & {value: SpectrumLabeledValueTypes, formatOptions?: any}; + + let domRef = useDOMRef(ref); + + 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} + +
+ ); +}) as (props: LabeledValueValueProps & LabeledValueProps & {ref?: DOMRef}) => ReactElement; + +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..4a1455369d2 --- /dev/null +++ b/packages/@react-spectrum/s2/stories/LabeledValue.stories.tsx @@ -0,0 +1,185 @@ +/* + * 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, Time, ZonedDateTime} from '@internationalized/date'; +import {Content, Heading, Text} 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 {style} from '../style' with {type: 'macro'}; + +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 StringValue: Story = { + args: { + label: 'Name', + value: 'Jane Smith' + }, + name: 'String' +}; + +export const StringListValue: Story = { + render: (args) => , + name: 'String array' +}; + +export const NumberValue: Story = { + render: (args) => , + name: 'Number' +}; + +export const NumberRangeValue: Story = { + render: (args) => , + name: 'RangeValue' +}; + +export const DateValue: Story = { + render: (args) => , + name: 'CalendarDate' +}; + +export const DateRangeValue: Story = { + render: (args) => ( + + ), + name: 'RangeValue' +}; + +export const CalendarDateTimeValue: Story = { + render: (args) => ( + + ), + name: 'CalendarDateTime' +}; + +export const CalendarDateTimeRangeValue: Story = { + render: (args) => ( + + ), + name: 'RangeValue' +}; + +export const ZonedDateTimeValue: Story = { + render: (args) => ( + + ), + name: 'ZonedDateTime' +}; + +export const TimeValue: Story = { + render: (args) => , + name: 'Time' +}; + +export const TimeRangeValue: Story = { + render: (args) => , + name: 'RangeValue