@@ -3,10 +3,10 @@ import styled from 'styled-components';
33import type { ChatInputFeedbackProps } from './types' ;
44import BaseBox from '~components/Box/BaseBox' ;
55import { ChatFeedback } from '~components/ChatFeedback' ;
6+ import { chatFeedbackMoodButtonSize } from '~components/ChatFeedback/chatFeedbackTokens' ;
67import type { ChatFeedbackControls } from '~components/ChatFeedback' ;
78import { Move } from '~components/Move' ;
8- import { useTheme } from '~components/BladeProvider' ;
9- import { makeSpace } from '~utils' ;
9+ import { makeSpace , makeSize , castWebType } from '~utils' ;
1010
1111/**
1212 * An even 4px inset.
@@ -17,13 +17,67 @@ import { makeSpace } from '~utils';
1717 * inside it. The mood buttons are 32px tall and already give the row its height, so anything more
1818 * than this is air.
1919 */
20+ /**
21+ * An even 4px inset.
22+ *
23+ * Deliberately *not* aligned with the composer's own 16px content padding: the prompt sits closer
24+ * to the edge of the surface than the placeholder below it does, which is what stops the two rows
25+ * reading as one list and keeps the prompt feeling like a header on the card rather than a line
26+ * inside it. The mood buttons already give the row its height, so anything more than this is air.
27+ */
2028const StripPadding = styled ( BaseBox ) ( ( { theme } ) => ( {
21- padding : makeSpace ( theme . spacing [ 2 ] ) ,
29+ /*
30+ * Horizontal only. The surface around this already contributes 8px above and below, and the
31+ * strip adding its own on top of that made the row sit lower in the container than the composer
32+ * sits in it — the prompt read as floating rather than as a header on the card.
33+ *
34+ * The row's height is set by the mood buttons regardless, so there is nothing here for vertical
35+ * padding to protect.
36+ */
37+ paddingTop : makeSpace ( theme . spacing [ 0 ] ) ,
38+ paddingBottom : makeSpace ( theme . spacing [ 0 ] ) ,
2239 // A touch more on the left, so the question clears the surface's rounded corner rather than
2340 // sitting tight against it. The right stays at 4px — the submit control needs no such relief.
2441 paddingLeft : makeSpace ( theme . spacing [ 3 ] ) ,
42+ paddingRight : makeSpace ( theme . spacing [ 2 ] ) ,
2543} ) ) ;
2644
45+ /**
46+ * Closes the frame around the composer, rather than letting the strip's height vanish with it.
47+ *
48+ * Ported from the prototype, where the wrapper's own box collapses while its contents fade. `Move`
49+ * fades and slides the strip but leaves the space it occupied at full height until the instant it
50+ * unmounts, so the composer held still through the whole animation and then snapped up 52px at the
51+ * end of it. Collapsing the height alongside the fade means the surface recedes with its contents.
52+ *
53+ * It sits *outside* `Move` on purpose. `BaseMotionEntryExit` does not render its child — it takes
54+ * the child's type and props and re-renders it as a motion element — and a transient prop does not
55+ * survive that round trip, so anything driven off `isVisible` has to own its own element.
56+ */
57+ const StripCollapse = styled . div < { $isVisible : boolean } > ( ( { theme, $isVisible } ) => {
58+ /*
59+ * The strip's open height, named exactly rather than guessed.
60+ *
61+ * Every step is held to the mood row's height, so this is that row plus the inset above — which
62+ * is what lets a `max-height` transition have a target at all, since `none` cannot animate.
63+ */
64+ const openHeight = chatFeedbackMoodButtonSize + theme . spacing [ 2 ] * 2 ;
65+
66+ /*
67+ * Locked to `Move`'s own timings: in on `xmoderate`/`entrance`, out on the faster `quick`/`exit`.
68+ * A collapse that outlasted the fade would be cut off part-closed by the unmount, and the
69+ * composer would finish the journey in a single jump — the exact thing this removes.
70+ */
71+ const duration = $isVisible ? theme . motion . duration . xmoderate : theme . motion . duration . quick ;
72+ const ease = castWebType ( $isVisible ? theme . motion . easing . entrance : theme . motion . easing . exit ) ;
73+
74+ return {
75+ overflow : 'hidden' ,
76+ maxHeight : $isVisible ? makeSize ( openHeight ) : '0px' ,
77+ transition : `max-height ${ duration } ms ${ ease } ` ,
78+ } ;
79+ } ) ;
80+
2781/**
2882 * The feedback prompt attached to the top of a `ChatInput`.
2983 *
@@ -45,8 +99,13 @@ const StripPadding = styled(BaseBox)(({ theme }) => ({
4599 *
46100 * The strip sits directly on top of the composer, so a step one pixel taller pushes the whole
47101 * composer down — at the exact moment the merchant is reading the strip. The content is held to
48- * the height of the mood row (`spacing[8]`, the height of Blade's own mood button) so every step
49- * occupies the same space and the swap changes *what* is on the strip and nothing else.
102+ * the height of the mood row, the tallest of the three steps, so every step occupies the same
103+ * space and the swap changes *what* is on the strip and nothing else.
104+ *
105+ * That height is imported rather than restated. It was a local `spacing[8]` until the mood glyph
106+ * grew and left it behind: the mood step became 44px while the tags step stayed at 38, and the
107+ * composer jumped 12px on every transition. Reading it from the same constant the button is
108+ * built from is what stops that from happening a second time.
50109 */
51110const ChatInputFeedback = ( {
52111 isVisible = true ,
@@ -70,8 +129,6 @@ const ChatInputFeedback = ({
70129 /** Lets the composer drive this flow — submit it, and release the tag on the way out. */
71130 controlsRef ?: React . MutableRefObject < ChatFeedbackControls | null > ;
72131} ) : React . ReactElement => {
73- const { theme } = useTheme ( ) ;
74-
75132 /*
76133 * A fresh `Move` per showing.
77134 *
@@ -87,44 +144,50 @@ const ChatInputFeedback = ({
87144 wasVisible . current = isVisible ;
88145
89146 return (
90- < Move key = { generation . current } isVisible = { isVisible } shouldUnmountWhenHidden type = "inout" >
91- { /*
147+ < StripCollapse $isVisible = { isVisible } >
148+ < Move key = { generation . current } isVisible = { isVisible } shouldUnmountWhenHidden type = "inout" >
149+ { /*
92150 Claims a layer of its own. The error slot below sits at `zIndex: 0` and the input card at
93151 `zIndex: 1`; the strip joins the card rather than the slot, which is what keeps its
94152 controls reachable while an error is mounted but not shown.
95153 */ }
96- < StripPadding position = "relative" zIndex = { 1 } >
97- < BaseBox display = "flex" alignItems = "center" minHeight = { makeSpace ( theme . spacing [ 8 ] ) } >
98- < ChatFeedback
99- question = { question }
100- feedbackIcons = { feedbackIcons }
101- moodConfig = { moodConfig }
102- isDisabled = { isDisabled }
103- onMoodSelect = { onMoodSelect }
104- onTagsChange = { onTagsChange }
105- isSubmitHidden = { isSubmitHidden }
106- controlsRef = { controlsRef }
107- onSubmit = { ( payload ) =>
108- onSubmit ?.( {
109- /*
110- * A blank composer is not a comment. Trimmed rather than passed through, so
111- * whitespace does not arrive as feedback someone has to read.
112- */
113- ...payload ,
114- comment : comment ?. trim ( ) ? comment . trim ( ) : payload . comment ,
115- } )
116- }
117- onDismiss = { onDismiss }
118- /*
119- * The strip spans the composer, so each step spreads to the full width and the
120- * trailing control lands above the send button. This is the case `isFullWidth` was
121- * added for; the floating bar is the one that opts out.
122- */
123- isFullWidth
124- />
125- </ BaseBox >
126- </ StripPadding >
127- </ Move >
154+ < StripPadding position = "relative" zIndex = { 1 } >
155+ < BaseBox
156+ display = "flex"
157+ alignItems = "center"
158+ minHeight = { makeSize ( chatFeedbackMoodButtonSize ) }
159+ >
160+ < ChatFeedback
161+ question = { question }
162+ feedbackIcons = { feedbackIcons }
163+ moodConfig = { moodConfig }
164+ isDisabled = { isDisabled }
165+ onMoodSelect = { onMoodSelect }
166+ onTagsChange = { onTagsChange }
167+ isSubmitHidden = { isSubmitHidden }
168+ controlsRef = { controlsRef }
169+ onSubmit = { ( payload ) =>
170+ onSubmit ?.( {
171+ /*
172+ * A blank composer is not a comment. Trimmed rather than passed through, so
173+ * whitespace does not arrive as feedback someone has to read.
174+ */
175+ ...payload ,
176+ comment : comment ?. trim ( ) ? comment . trim ( ) : payload . comment ,
177+ } )
178+ }
179+ onDismiss = { onDismiss }
180+ /*
181+ * The strip spans the composer, so each step spreads to the full width and the
182+ * trailing control lands above the send button. This is the case `isFullWidth` was
183+ * added for; the floating bar is the one that opts out.
184+ */
185+ isFullWidth
186+ />
187+ </ BaseBox >
188+ </ StripPadding >
189+ </ Move >
190+ </ StripCollapse >
128191 ) ;
129192} ;
130193
0 commit comments