Skip to content

Commit 5b6379f

Browse files
committed
feat(examples): add StickySave composite ui-pattern
Persistent save/cancel bar for long forms, exported from the composites barrel with a storybook story.
1 parent 264325c commit 5b6379f

3 files changed

Lines changed: 561 additions & 0 deletions

File tree

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import {
2+
Button,
3+
Spinner,
4+
Text,
5+
makeStyles,
6+
mergeClasses,
7+
tokens,
8+
} from "@fluentui/react-components";
9+
import { SaveRegular } from "@fluentui/react-icons";
10+
import { type ReactNode, forwardRef } from "react";
11+
12+
export interface StickySaveProps {
13+
/**
14+
* Controls whether the bar is shown. Drive this from the form's "dirty"
15+
* state so the bar slides into view as soon as the user makes a change.
16+
*/
17+
visible: boolean;
18+
19+
/** Called when the primary save action is triggered. */
20+
onSave: () => void;
21+
22+
/** Called when the cancel / discard action is triggered. */
23+
onCancel: () => void;
24+
25+
/** Label for the primary save button. */
26+
saveLabel?: string;
27+
28+
/** Label for the cancel button. */
29+
cancelLabel?: string;
30+
31+
/**
32+
* Message shown on the leading edge of the bar, e.g. an unsaved-changes
33+
* notice. Pass a string or custom node; omit to hide.
34+
*/
35+
message?: ReactNode;
36+
37+
/**
38+
* When `true`, the save button shows a spinner and both actions are
39+
* disabled while the save request is in flight.
40+
*/
41+
saving?: boolean;
42+
43+
/**
44+
* Disables the save button, e.g. while the form is invalid. The cancel
45+
* action stays available so the user can always discard changes.
46+
*/
47+
saveDisabled?: boolean;
48+
49+
/** Accessible label for the bar region. */
50+
ariaLabel?: string;
51+
52+
/** Optional CSS class applied to the bar. */
53+
className?: string;
54+
}
55+
56+
const useStyles = makeStyles({
57+
root: {
58+
position: "sticky",
59+
bottom: 0,
60+
zIndex: 1,
61+
display: "flex",
62+
alignItems: "center",
63+
justifyContent: "space-between",
64+
gap: tokens.spacingHorizontalM,
65+
boxSizing: "border-box",
66+
width: "100%",
67+
minHeight: "56px",
68+
paddingBlock: tokens.spacingVerticalS,
69+
paddingInline: tokens.spacingHorizontalL,
70+
backgroundColor: tokens.colorNeutralBackground1,
71+
borderTop: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`,
72+
boxShadow: tokens.shadow16,
73+
// Slide-up + fade-in when appearing.
74+
transitionProperty: "transform, opacity",
75+
transitionDuration: tokens.durationNormal,
76+
transitionTimingFunction: tokens.curveDecelerateMax,
77+
"@media (prefers-reduced-motion: reduce)": {
78+
transitionDuration: "1ms",
79+
},
80+
},
81+
82+
hidden: {
83+
transform: "translateY(100%)",
84+
opacity: 0,
85+
pointerEvents: "none",
86+
transitionTimingFunction: tokens.curveAccelerateMax,
87+
},
88+
89+
visible: {
90+
transform: "translateY(0)",
91+
opacity: 1,
92+
},
93+
94+
message: {
95+
minWidth: 0,
96+
color: tokens.colorNeutralForeground2,
97+
overflow: "hidden",
98+
textOverflow: "ellipsis",
99+
whiteSpace: "nowrap",
100+
},
101+
102+
actions: {
103+
display: "flex",
104+
alignItems: "center",
105+
gap: tokens.spacingHorizontalS,
106+
flexShrink: 0,
107+
},
108+
});
109+
110+
/**
111+
* StickySave — a persistent save/cancel bar for long forms.
112+
*
113+
* Pins to the bottom of its scroll container (`position: sticky`) so the save
114+
* and cancel actions stay reachable without scrolling to the end of a form
115+
* that is taller than the viewport. Slides into view when `visible` becomes
116+
* `true` (drive this from the form's dirty state) and slides out when the
117+
* changes are saved or discarded.
118+
*
119+
* **Fluent Guidelines Applied:**
120+
* - Token-driven styling via `makeStyles` + Fluent `tokens` exclusively
121+
* - Accessibility: bar is a labelled `role="region"`; hidden from AT and
122+
* keyboard focus (`aria-hidden`, `pointer-events: none`) while not visible
123+
* - Respects `prefers-reduced-motion`
124+
*
125+
* @example
126+
* const [dirty, setDirty] = useState(false);
127+
* <StickySave
128+
* visible={dirty}
129+
* message="You have unsaved changes"
130+
* onSave={() => saveForm().then(() => setDirty(false))}
131+
* onCancel={() => resetForm()}
132+
* />
133+
*/
134+
export const StickySave = forwardRef<HTMLDivElement, StickySaveProps>(
135+
(
136+
{
137+
visible,
138+
onSave,
139+
onCancel,
140+
saveLabel = "Save",
141+
cancelLabel = "Cancel",
142+
message = "You have unsaved changes",
143+
saving = false,
144+
saveDisabled = false,
145+
ariaLabel = "Unsaved changes",
146+
className,
147+
},
148+
ref
149+
) => {
150+
const styles = useStyles();
151+
152+
return (
153+
<div
154+
ref={ref}
155+
role="region"
156+
aria-label={ariaLabel}
157+
aria-hidden={!visible}
158+
inert={!visible ? true : undefined}
159+
className={mergeClasses(
160+
styles.root,
161+
visible ? styles.visible : styles.hidden,
162+
className
163+
)}
164+
>
165+
{message ? (
166+
<Text className={styles.message} truncate wrap={false}>
167+
{message}
168+
</Text>
169+
) : (
170+
<span />
171+
)}
172+
173+
<div className={styles.actions}>
174+
<Button
175+
appearance="secondary"
176+
onClick={onCancel}
177+
disabled={saving}
178+
tabIndex={visible ? 0 : -1}
179+
>
180+
{cancelLabel}
181+
</Button>
182+
<Button
183+
appearance="primary"
184+
icon={saving ? <Spinner size="tiny" /> : <SaveRegular />}
185+
onClick={onSave}
186+
disabled={saving || saveDisabled}
187+
tabIndex={visible ? 0 : -1}
188+
>
189+
{saveLabel}
190+
</Button>
191+
</div>
192+
</div>
193+
);
194+
}
195+
);
196+
197+
StickySave.displayName = "StickySave";

examples/src/storybook/ui-patterns/components/composites/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
* - EmptyState: Hero message for empty content areas
2222
* - DataTable: Composable table with Root/Header/Row pattern
2323
* - Pagination: Page navigation with item range display
24+
* - StickySave: Persistent save/cancel bar for long forms
2425
*/
2526

2627
export { FormField, type FormFieldProps } from "./FormField";
@@ -85,6 +86,7 @@ export {
8586
type DataTableCellProps,
8687
} from "./DataTable";
8788
export { Pagination, type PaginationProps } from "./Pagination";
89+
export { StickySave, type StickySaveProps } from "./StickySave";
8890
export {
8991
Wizard,
9092
type WizardProps,

0 commit comments

Comments
 (0)