[material-ui][ButtonGroup] Compute per-button position classes via context#46712
[material-ui][ButtonGroup] Compute per-button position classes via context#46712theo-sim-dev wants to merge 2 commits intomui:masterfrom
Conversation
…OM/JSX - Add register/unregister/getPosition to ButtonGroupContext - Button registers its root and applies first/middle/last classes from context - Merge grouped class from context; no :first-child/DOM-type heuristics - Works with wrapped/conditional children and custom Button components - Avoid leaking runtime props to the DOM - Add unit tests for single child, wrapped children, and dynamic visibility - Fixes regression since v5.14.9 where custom Buttons broke grouped styles - Fixes mui#39488
Netlify deploy previewhttps://deploy-preview-46712--material-ui.netlify.app/ Bundle size report
|
ZeeshanTamboli
left a comment
There was a problem hiding this comment.
@theo-sim-dev The implementation looks promising, and I confirmed it works: https://stackblitz.com/edit/prgp5bra.
I’ve left a few initial comments. Let’s also hear what @siriwatknp thinks.
| const id = Symbol('button-group-item'); | ||
| orderCounter.current += 1; | ||
| itemsRef.current.set(id, { node, order: orderCounter.current }); | ||
| setVersion((v) => v + 1); |
There was a problem hiding this comment.
Let's use full words as variable names. This improves code readability. Same in multiple other places.
| return { id }; | ||
| }, []); | ||
|
|
||
| const unregister = React.useCallback((h) => { |
There was a problem hiding this comment.
| const unregister = React.useCallback((h) => { | |
| const unregister = React.useCallback((handler) => { |
| const classes = useUtilityClasses(ownerState); | ||
|
|
||
| // Live set of buttons, in mount/render order | ||
| const itemsRef = React.useRef(new Map()); // Map<symbol, { node, order }> |
There was a problem hiding this comment.
Why itemsRef is not an object, but a Map?
| .map(([id, v]) => ({ id, ...v })) | ||
| .sort((a, b) => a.order - b.order); | ||
|
|
||
| if (arr.length <= 1) { |
There was a problem hiding this comment.
Shouldn’t this just check for exactly one button?
| if (arr.length <= 1) { | |
| if (arr.length === 1) { |
In what situation would the group have zero registered buttons? It seems that once a ButtonGroup renders, there’s always at least one child registered; otherwise the group wouldn’t be useful. If 0 ever happened, it would only be during the brief unmount of all children, which isn’t a case that needs position styling anyway.
So arr.length === 1 should be enough.
| register: groupRegister, | ||
| unregister: groupUnregister, | ||
| getPosition: groupGetPosition, | ||
| version: groupVersion, |
There was a problem hiding this comment.
Is the version state only meant to force re-renders? Also, I don’t see groupVersion actually being used inside Button.
| const rootRef = React.useRef(null); | ||
| const combinedRef = useForkRef(ref, rootRef); | ||
|
|
||
| // Opaque handle returned by ButtonGroupContext.register(...) |
There was a problem hiding this comment.
What do you mean by Opaque handle? What does it mean?
|
|
||
| // Register on mount, unregister on unmount. | ||
| // Passive effect avoids parent setState during layout-unmount -> re-entrancy loops in dev. | ||
| React.useEffect(() => { |
There was a problem hiding this comment.
I think we should use useEnhancedEffect from utils.
| if (!groupRegister || !groupUnregister) { | ||
| // Not inside a ButtonGroup, so no need to register | ||
| return; | ||
| } // not inside a ButtonGroup |
There was a problem hiding this comment.
Above comment is enough.
| } // not inside a ButtonGroup | |
| } |
| // position class from getPosition() should be applied after re-render | ||
| expect(btn).to.have.class(buttonGroupClasses.firstButton); | ||
| expect(btn).not.to.have.class(buttonGroupClasses.middleButton); | ||
| expect(btn).not.to.have.class(buttonGroupClasses.lastButton); |
There was a problem hiding this comment.
I’m not clear on this—if there’s only one Button, why does it still get the firstButton class?
| variant?: ButtonGroupProps['variant']; | ||
|
|
||
| // Child button calls this once when it mounts | ||
| register?: (node: HTMLElement | null) => ButtonGroupItemHandle; |
There was a problem hiding this comment.
Why to support null?
| register?: (node: HTMLElement | null) => ButtonGroupItemHandle; | |
| register?: (node: HTMLElement) => ButtonGroupItemHandle; |
|
Thank you for submitting the PR but the issue is related to styles. I think we should try to find a solution using CSS first. Now that we have |
|
@siriwatknp We support Firefox v115, which doesn't support |
|
|
||
| const classes = useUtilityClasses(ownerState); | ||
|
|
||
| // Live set of buttons, in mount/render order |
There was a problem hiding this comment.
I think this isn't 100% reliable, and it's necessary to sort all the registered Buttons by document position
You could have a look at how floating UI does this with one state: https://github.com/floating-ui/floating-ui/blob/master/packages/react/src/components/FloatingList.tsx#L58-L81
|
It's decided to not fix this. See #39488 (comment). Closing this PR. |
Fixes #39488
Summary
Restore correct
first,middleandlaststyles inButtonGroupwith custom, wrapped, or conditionally renderedButtons by using runtime registration via context (no JSX/DOM heuristics, no Emotion:first/last-childdependence).Changes
ButtonGroup: addregister,unregister,getPositioncontext; ensuring not to leak these internal props to DOM.Button: register actual root, apply position class from context, mergeclasses.grouped.Tests
Others