Bläddra i källkod

Neo: a loading button keeps its size, with the spinner centred over its content

A loading NeoButton put a spinner in its image slot. A button with only a
label (the login form's "Log in") gained a spinner it had no room for, so it
grew and the row shifted; `loadingLabel` also swapped the visible text,
changing the width again.

Now the label and image stay where they are, at opacity 0, and the spinner
is drawn centred over them in the label colour. Every loading button keeps
its exact size: measured in the /pg playground, Save with an icon stays
100x44, the label-only "Log in" 76x44 and an icon-only button 44x44, each
with one spinner at the centre, in light and dark. `loadingLabel` now names
the button for screen readers while it loads (the aria-label becomes
"Saving…") instead of changing what is drawn. A button with no label or
image still shows the spinner in place of the image, and custom children
are hidden the same way. Native glass buttons (Expo UI) are unchanged.

The playground's Loading section gets a label-only and an icon-only button
next to the Save example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Andrey Yasko 5 timmar sedan
förälder
incheckning
f68fc1ee65

+ 20 - 1
neo/packages/app/components/page-layout/_playground/playground.js

@@ -561,7 +561,7 @@ export default function PageLayoutPlayground({ data, children }) {
 
                     <Section
                         title="Loading"
-                        hint="Replaces the image with a spinner; rest of the label stays put."
+                        hint="The label and image stay in place, invisible, under a centred spinner, so the button keeps its size. loadingLabel names it for screen readers."
                     >
                         <NeoButton
                             style="borderedProminent"
@@ -574,6 +574,25 @@ export default function PageLayoutPlayground({ data, children }) {
                                 setTimeout(() => setLoading(false), 1500);
                             }}
                         />
+                        <NeoButton
+                            style="borderedProminent"
+                            label="Log in"
+                            loading={loading}
+                            onPress={() => {
+                                setLoading(true);
+                                setTimeout(() => setLoading(false), 1500);
+                            }}
+                        />
+                        <NeoButton
+                            style="bordered"
+                            image="Trash"
+                            accessibilityLabel="Delete"
+                            loading={loading}
+                            onPress={() => {
+                                setLoading(true);
+                                setTimeout(() => setLoading(false), 1500);
+                            }}
+                        />
                     </Section>
 
                     <Section

+ 21 - 9
neo/packages/app/design/controls/neo-button/neo-button.tsx

@@ -232,7 +232,10 @@ function renderLabel({
     /** Native `showTitleFromSize` below its breakpoint: keep the padding, drop the Text. */
     hideLabel?: boolean;
 }) {
-    const effectiveImage = loading ? '_loading' : image;
+    // While loading, the label and image keep their place, invisible, under the
+    // centred spinner, so the button keeps its size. Only a button with neither
+    // draws the spinner here, in the image slot.
+    const effectiveImage = (loading && !label && !image) ? '_loading' : image;
     if (!label && !effectiveImage) return null;
 
     const spreadTrailing =
@@ -248,6 +251,7 @@ function renderLabel({
                 'flex-row items-center min-w-0 max-w-full',
                 spreadTrailing && 'flex-1 w-full justify-between',
                 useGapClass && labelGapCls,
+                loading && effectiveImage !== '_loading' && 'opacity-0',
             )}
             style={(spreadTrailing || useGapClass) ? undefined : { columnGap: labelGap }}
         >
@@ -580,13 +584,13 @@ const NeoButtonStyled = (props: NeoButtonProps & { titleHidden?: boolean }) => {
     });
 
     // Effective content props.
-    // While `loading=true`, `loadingLabel` (when provided) replaces the
-    // visible label so consumers don't need a `<Text>{loading ? 'Saving…' : 'Save'}</Text>`
-    // expression at every call site. The image slot still gets the spinner
-    // via `renderLabel`'s loading swap.
-    const effectiveLabel = (loading && loadingLabel != null && loadingLabel !== '')
+    // While `loading=true` the content stays as it is (hidden under a centred
+    // spinner, so the button keeps its size), and `loadingLabel`, when given,
+    // becomes the accessible name instead of swapping the visible text.
+    const effectiveLabel = label ?? title ?? '';
+    const loadingName = loading && typeof loadingLabel === 'string' && loadingLabel !== ''
         ? loadingLabel
-        : (label ?? title ?? '');
+        : undefined;
     const effectiveImage = image ?? systemImage ?? resolved.defaultImage;
     const effectivePlacement = imagePlacement ?? resolved.imagePlacement;
 
@@ -602,7 +606,7 @@ const NeoButtonStyled = (props: NeoButtonProps & { titleHidden?: boolean }) => {
     const soundOverride = usePressCueOverride();
 
     const accessibleName =
-        accessibilityLabel ?? alt ?? getAccessibleName(undefined, tooltip, effectiveLabel, effectiveImage);
+        loadingName ?? accessibilityLabel ?? alt ?? getAccessibleName(undefined, tooltip, effectiveLabel, effectiveImage);
 
     /* ----------------------- interaction state -------------------------- */
 
@@ -880,8 +884,11 @@ const NeoButtonStyled = (props: NeoButtonProps & { titleHidden?: boolean }) => {
         ? { ...classNames, text: cn(classNames?.text, titleFromSizeCls) }
         : classNames;
 
+    // The spinner sits over the content, which stays in place invisible (see
+    // `renderLabel`). A button with no content draws it inline instead.
+    const spinnerOverlay = loading && (isTitle || !!effectiveImage || isCustomChildren);
     const labelContent = isCustomChildren
-        ? children
+        ? (loading ? <Row className="flex-row items-center opacity-0">{children}</Row> : children)
         : renderLabel({
             label: effectiveLabel,
             image: effectiveImage,
@@ -909,6 +916,11 @@ const NeoButtonStyled = (props: NeoButtonProps & { titleHidden?: boolean }) => {
             <NeoButtonContentContext.Provider value={contentValue}>
                 {labelContent}
             </NeoButtonContentContext.Provider>
+            {spinnerOverlay ? (
+                <View className="absolute inset-0 items-center justify-center pointer-events-none">
+                    <NeoSpinner className={cn(textCls, classNames?.image)} color={tintColor} />
+                </View>
+            ) : null}
             {addonContent && (
                 isTitle ? <View className="z-10 ml-1">{addonContent}</View> : <View className="absolute top-0 right-0 w-full h-full z-20 pointer-events-none">{addonContent}</View>
             )}