From 3a5d7d83017b738c5a08068928d1a225a3527311 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Tue, 10 Mar 2026 19:03:04 -0500 Subject: [PATCH 1/2] feat: S2 ListView HCM (#9760) * feat: S2 ListView HCM * fix lint * Fix pre-existing issue in Menu * Fix typescript * fix Section headers * fix bad description color * more intuitive control for font color of description * dont' forget menu * fix description in ListView HCM --- packages/@react-spectrum/s2/src/ComboBox.tsx | 2 +- packages/@react-spectrum/s2/src/ListView.tsx | 62 +++++++++++++++---- packages/@react-spectrum/s2/src/Menu.tsx | 26 ++++---- packages/@react-spectrum/s2/src/Picker.tsx | 2 +- packages/@react-spectrum/s2/src/TableView.tsx | 5 +- .../@react-spectrum/s2/src/TabsPicker.tsx | 2 +- .../s2/stories/ListView.stories.tsx | 3 +- .../s2/style/__tests__/style-macro.test.js | 17 +++++ .../@react-spectrum/s2/style/style-macro.ts | 15 +++-- 9 files changed, 102 insertions(+), 32 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index 32bbeb3feb8..87509f9c5d9 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -694,7 +694,7 @@ const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps diff --git a/packages/@react-spectrum/s2/src/ListView.tsx b/packages/@react-spectrum/s2/src/ListView.tsx index b749a5a4404..6a201f6a84d 100644 --- a/packages/@react-spectrum/s2/src/ListView.tsx +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -289,9 +289,15 @@ const listitem = style({ ...focusRing(), - outlineOffset: -2, + outlineOffset: { + default: -2, + forcedColors: -3 + }, + outlineWidth: { + default: 2, + forcedColors: '[3px]' + }, + outlineColor: { + default: 'focus-ring', + forcedColors: { + default: 'Highlight', + selectionStyle: { + highlight: 'ButtonBorder' + } + } + }, position: 'absolute', inset: 0, top: { @@ -567,7 +606,8 @@ export let description = style({ font: 'ui-sm', color: { default: baseColor('neutral-subdued'), - isDisabled: 'disabled' + isDisabled: 'disabled', + forcedColors: 'inherit' }, transition: 'default' }); @@ -770,7 +810,7 @@ export function ListViewItem(props: ListViewItemProps): ReactNode { isLastItem: isLastItem(id, state) }) } /> - {renderProps.isFocusVisible && + {renderProps.isFocusVisible &&
& }, color: { default: baseColor('neutral'), + isDisabled: 'disabled', forcedColors: { default: 'ButtonText', - isFocused: 'HighlightText' - }, - isDisabled: { - default: 'disabled', - forcedColors: 'GrayText' + isFocused: 'HighlightText', + isDisabled: 'GrayText' } }, position: 'relative', @@ -278,7 +276,7 @@ export let label = style<{size: string}>({ marginTop: '--labelPadding' }); -export let description = style({ +export let description = style<{size: 'S' | 'M' | 'L' | 'XL', isFocused: boolean, isDisabled: boolean}>({ gridArea: 'description', font: { default: 'ui-sm', @@ -294,7 +292,10 @@ export let description = style({ // Ideally this would use the same token as hover, but we don't have access to that here. // TODO: should we always consider isHovered and isFocused to be the same thing? isFocused: 'gray-800', - isDisabled: 'disabled' + isDisabled: 'disabled', + forcedColors: { + default: 'inherit' + } }, transition: 'default' }); @@ -304,7 +305,7 @@ let value = style({ marginStart: 8 }); -let keyboard = style<{size: 'S' | 'M' | 'L' | 'XL', isDisabled: boolean}>({ +let keyboard = style<{size: 'S' | 'M' | 'L' | 'XL', isDisabled: boolean, isFocused: boolean}>({ gridArea: 'keyboard', marginStart: 8, font: 'ui', @@ -313,7 +314,7 @@ let keyboard = style<{size: 'S' | 'M' | 'L' | 'XL', isDisabled: boolean}>({ default: 'gray-600', isDisabled: 'disabled', forcedColors: { - isDisabled: 'GrayText' + default: 'inherit' } }, unicodeBidi: 'plaintext' @@ -386,7 +387,7 @@ export const Menu = /*#__PURE__*/ (forwardRef as forwardRefType)(function Menu { let {children} = props; let checkboxRenderProps = {...renderProps, size, isFocused: false, isFocusVisible: false, isIndeterminate: false, isReadOnly: false, isInvalid: false, isRequired: false}; + let isFocused = (renderProps.hasSubmenu && renderProps.isOpen) || renderProps.isFocused; return ( <> {renderProps.selectionMode === 'single' && !renderProps.hasSubmenu && } diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index b092ec8a610..829230c5575 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -448,7 +448,7 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick }], [TextContext, { slots: { - description: {styles: description({size})} + 'description': {styles: description({size, isFocused: false, isDisabled: false})} } }] ]}> diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 94939aa6741..829e259ddeb 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -476,7 +476,10 @@ const cellFocus = { }, outlineOffset: -2, outlineWidth: 2, - outlineColor: 'focus-ring', + outlineColor: { + default: 'focus-ring', + forcedColors: 'Highlight' + }, borderRadius: '[6px]' } as const; diff --git a/packages/@react-spectrum/s2/src/TabsPicker.tsx b/packages/@react-spectrum/s2/src/TabsPicker.tsx index ac6119ee1e0..47d75e6942b 100644 --- a/packages/@react-spectrum/s2/src/TabsPicker.tsx +++ b/packages/@react-spectrum/s2/src/TabsPicker.tsx @@ -271,7 +271,7 @@ function Picker(props: PickerProps, ref: FocusableRef diff --git a/packages/@react-spectrum/s2/stories/ListView.stories.tsx b/packages/@react-spectrum/s2/stories/ListView.stories.tsx index d91c0a26820..d2fd339d4d0 100644 --- a/packages/@react-spectrum/s2/stories/ListView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/ListView.stories.tsx @@ -33,7 +33,8 @@ const meta: Meta = { }, tags: ['autodocs'], argTypes: { - ...categorizeArgTypes('Events', ['onSelectionChange']) + ...categorizeArgTypes('Events', ['onSelectionChange']), + children: {table: {disable: true}} }, title: 'ListView', args: { diff --git a/packages/@react-spectrum/s2/style/__tests__/style-macro.test.js b/packages/@react-spectrum/s2/style/__tests__/style-macro.test.js index 79efca76d19..acf21462e48 100644 --- a/packages/@react-spectrum/s2/style/__tests__/style-macro.test.js +++ b/packages/@react-spectrum/s2/style/__tests__/style-macro.test.js @@ -366,6 +366,23 @@ describe('style-macro', () => { expect(js({isSelected: true})).toMatchInlineSnapshot('" ple12 -macro-dynamic-37zkvn"'); }); + it('inherits parent default when nested branch has no default key', () => { + let {css, js} = testStyle({ + color: { + forcedColors: { + default: 'ButtonText', + variant: { + highlight: {isSelected: 'HighlightText'} + } + } + } + }); + // forcedColors.default should apply when variant=highlight but !isSelected + expect(css).toContain('ButtonText'); + expect(js({variant: 'highlight'})).toMatchInlineSnapshot('" plb12 -macro-dynamic-1owjb9s"'); + expect(js({variant: 'highlight', isSelected: true})).toMatchInlineSnapshot('" ple12 -macro-dynamic-37zkvn"'); + }); + it('should expand shorthand properties to longhands', () => { let {js, css} = testStyle({ padding: 24 diff --git a/packages/@react-spectrum/s2/style/style-macro.ts b/packages/@react-spectrum/s2/style/style-macro.ts index 8286f4dfa8a..c8e604f0fe5 100644 --- a/packages/@react-spectrum/s2/style/style-macro.ts +++ b/packages/@react-spectrum/s2/style/style-macro.ts @@ -223,7 +223,7 @@ export function createTheme(theme: T): StyleFunction(theme: T): StyleFunction Date: Wed, 11 Mar 2026 05:47:29 +0530 Subject: [PATCH 2/2] Fix: Wrap commit() in flushSync on Enter in useNumberField When Enter is pressed in NumberField, commit() was called without flushSync, unlike the blur handler which wraps commit() in flushSync. This caused controlled form libraries reading React state immediately after Enter to receive stale values instead of the committed value. The browser synthesizes a trusted click on the form's submit button when Enter is pressed, firing onSubmit before the unguarded commit() has flushed to React state. This change wraps commit() in flushSync on Enter, making it consistent with the blur behavior and ensuring the value is flushed synchronously before any consumer reads form state. Fixes #9671 (#9683) --- packages/@react-aria/numberfield/src/useNumberField.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/@react-aria/numberfield/src/useNumberField.ts b/packages/@react-aria/numberfield/src/useNumberField.ts index 4d5f607ea31..06e5d42e526 100644 --- a/packages/@react-aria/numberfield/src/useNumberField.ts +++ b/packages/@react-aria/numberfield/src/useNumberField.ts @@ -219,7 +219,9 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt } if (e.key === 'Enter') { - commit(); + flushSync(() => { + commit(); + }); commitValidation(); } else { e.continuePropagation();