@snapdragonsnursery/react-components 1.21.0 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@snapdragonsnursery/react-components",
3
- "version": "1.21.0",
3
+ "version": "1.23.0",
4
4
  "description": "",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -216,10 +216,10 @@ const ChildSearchModal = ({
216
216
  }
217
217
 
218
218
  // Handle status filtering
219
+ // Important: when status is "all", do not send active_only=true.
220
+ // The status dropdown is the source of truth for active/inactive/all.
219
221
  if (advancedFilters.status && advancedFilters.status !== "all") {
220
222
  params.append("status", advancedFilters.status);
221
- } else if (activeOnly) {
222
- params.append("active_only", "true");
223
223
  }
224
224
 
225
225
  // Add room filter
@@ -293,7 +293,6 @@ const ChildSearchModal = ({
293
293
  pagination.pageSize,
294
294
  siteId,
295
295
  siteIds,
296
- activeOnly,
297
296
  advancedFilters,
298
297
  applicationContext,
299
298
  bypassPermissions,
@@ -348,10 +348,13 @@ const ChildSearchPage = ({
348
348
  }
349
349
 
350
350
  // Handle status filtering
351
- if (debouncedAdvancedFilters.status && debouncedAdvancedFilters.status !== "all") {
351
+ // Important: when status is "all", do not send active_only=true.
352
+ // The status dropdown is the source of truth for active/inactive/all.
353
+ if (
354
+ debouncedAdvancedFilters.status &&
355
+ debouncedAdvancedFilters.status !== "all"
356
+ ) {
352
357
  params.append("status", debouncedAdvancedFilters.status);
353
- } else if (activeOnly) {
354
- params.append("active_only", "true");
355
358
  }
356
359
 
357
360
  // Add room filter
@@ -431,7 +434,6 @@ const ChildSearchPage = ({
431
434
  pagination.pageSize,
432
435
  siteId,
433
436
  siteIds,
434
- activeOnly,
435
437
  debouncedAdvancedFilters,
436
438
  applicationContext,
437
439
  bypassPermissions,
@@ -639,13 +639,13 @@ const EmployeeSearchModal = ({
639
639
  }
640
640
 
641
641
  // Handle status filtering
642
+ // Important: when status is "all", do not send active_only=true.
643
+ // The status dropdown is the source of truth for active/inactive/all.
642
644
  if (
643
645
  debouncedAdvancedFilters.status &&
644
646
  debouncedAdvancedFilters.status !== "all"
645
647
  ) {
646
648
  params.append("status", debouncedAdvancedFilters.status);
647
- } else if (activeOnly) {
648
- params.append("active_only", "true");
649
649
  }
650
650
 
651
651
  // Add role filter
@@ -759,7 +759,6 @@ const EmployeeSearchModal = ({
759
759
  pagination.pageSize,
760
760
  siteId,
761
761
  siteIds,
762
- activeOnly,
763
762
  debouncedAdvancedFilters,
764
763
  applicationContext,
765
764
  bypassPermissions,
@@ -450,10 +450,13 @@ const EmployeeSearchPage = ({
450
450
  }
451
451
 
452
452
  // Handle status filtering
453
- if (debouncedAdvancedFilters.status && debouncedAdvancedFilters.status !== "all") {
453
+ // Important: when status is "all", do not send active_only=true.
454
+ // The status dropdown is the source of truth for active/inactive/all.
455
+ if (
456
+ debouncedAdvancedFilters.status &&
457
+ debouncedAdvancedFilters.status !== "all"
458
+ ) {
454
459
  params.append("status", debouncedAdvancedFilters.status);
455
- } else if (activeOnly) {
456
- params.append("active_only", "true");
457
460
  }
458
461
 
459
462
  // Add role filter
@@ -581,7 +584,6 @@ const EmployeeSearchPage = ({
581
584
  pagination.pageSize,
582
585
  siteId,
583
586
  siteIds,
584
- activeOnly,
585
587
  debouncedAdvancedFilters,
586
588
  applicationContext,
587
589
  bypassPermissions,
@@ -1,33 +1,35 @@
1
1
  // FloatingActionButton.jsx
2
2
  // ---------------------------------
3
3
  // A shadcn-styled Floating Action Button (FAB).
4
- // - Single fluid button that contains both icon and (optional) label
5
- // - Label reveal is controlled by showLabel: 'hover' | 'always' | 'never'
6
- // - Subtle icon and padding animation on hover when showLabel='hover'
7
- // - Supports fixed positioning to screen corners
4
+ // - Single action mode: icon + onClick. Label reveal controlled by showLabel: 'hover' | 'always' | 'never'.
5
+ // - Menu mode: icon + actions array. FAB expands into a cohesive speed-dial; action items bloom
6
+ // out of the button with staggered animation. Icon swaps to X when open.
7
+ // - Supports fixed positioning to screen corners.
8
8
  //
9
- // Example usage:
9
+ // Example usage (single action):
10
10
  //
11
- // import { FloatingActionButton } from "@snapdragonsnursery/react-components";
12
- // import { Plus } from "lucide-react";
11
+ // <FloatingActionButton icon={Plus} label="Create" onClick={() => {}} showLabel="hover" position="bottom-right" />
13
12
  //
14
- // export default function Example() {
15
- // return (
16
- // <FloatingActionButton
17
- // icon={Plus}
18
- // label="Create"
19
- // onClick={() => console.log("Create clicked")}
20
- // showLabel="hover"
21
- // position="bottom-right"
22
- // />
23
- // );
24
- // }
13
+ // Example usage (menu mode):
14
+ //
15
+ // <FloatingActionButton
16
+ // icon={Plus}
17
+ // actions={[
18
+ // { icon: FileText, label: "Send request form", onClick: () => {} },
19
+ // { icon: PenLine, label: "Log medication", onClick: () => {} },
20
+ // ]}
21
+ // position="bottom-right"
22
+ // menuAriaLabel="Medication actions"
23
+ // />
25
24
  //
26
25
  // Notes:
27
- // - Provide ariaLabel if label is omitted.
26
+ // - Provide ariaLabel (single) or menuAriaLabel (menu) when label is omitted.
28
27
  // - Uses Tailwind CSS + shadcn design tokens (bg-primary, text-primary-foreground, etc.).
28
+ // - Menu mode renders a speed-dial: action items bloom vertically from the FAB with staggered
29
+ // transitions, keeping the whole group visually unified.
29
30
 
30
31
  import * as React from "react";
32
+ import { X } from "lucide-react";
31
33
  import { cn } from "../../lib/utils";
32
34
  import { Button } from "./button.jsx";
33
35
 
@@ -39,22 +41,59 @@ const POSITION_CLASSES = {
39
41
  none: "",
40
42
  };
41
43
 
44
+ // For top positions, items bloom downward; for bottom positions, upward.
45
+ const isTopPosition = (position) =>
46
+ position === "top-right" || position === "top-left";
47
+
48
+ // Label appears on the opposite side of the screen edge.
49
+ const labelSide = (position) =>
50
+ position === "bottom-right" || position === "top-right" ? "left" : "right";
51
+
42
52
  function FloatingActionButton({
43
53
  icon: Icon,
44
54
  label = "",
45
55
  onClick,
56
+ actions,
46
57
  showLabel,
47
- // Deprecated: expandOnHover kept for backwards compatibility. If provided and showLabel is undefined,
48
- // we map: true -> 'hover', false -> 'never'.
58
+ // Deprecated: expandOnHover kept for backwards compatibility.
49
59
  expandOnHover,
50
60
  position = "bottom-right",
51
61
  className,
52
62
  disabled = false,
53
63
  ariaLabel,
64
+ menuAriaLabel,
54
65
  positionClassName,
55
66
  colorClassName,
67
+ menuButtonClassName,
68
+ menuLabelClassName,
56
69
  ...props
57
70
  }) {
71
+ const [menuOpen, setMenuOpen] = React.useState(false);
72
+ const isMenuMode = actions != null && actions.length > 0;
73
+ const containerRef = React.useRef(null);
74
+
75
+ // Close on outside click
76
+ React.useEffect(() => {
77
+ if (!menuOpen) return;
78
+ const handler = (e) => {
79
+ if (containerRef.current && !containerRef.current.contains(e.target)) {
80
+ setMenuOpen(false);
81
+ }
82
+ };
83
+ document.addEventListener("pointerdown", handler);
84
+ return () => document.removeEventListener("pointerdown", handler);
85
+ }, [menuOpen]);
86
+
87
+ // Close on Escape
88
+ React.useEffect(() => {
89
+ if (!menuOpen) return;
90
+ const handler = (e) => {
91
+ if (e.key === "Escape") setMenuOpen(false);
92
+ };
93
+ document.addEventListener("keydown", handler);
94
+ return () => document.removeEventListener("keydown", handler);
95
+ }, [menuOpen]);
96
+
58
97
  const resolvedShowLabel = React.useMemo(() => {
59
98
  if (showLabel) return showLabel;
60
99
  if (typeof expandOnHover === "boolean") {
@@ -65,6 +104,7 @@ function FloatingActionButton({
65
104
 
66
105
  const containerClasses = cn(
67
106
  positionClassName || (POSITION_CLASSES[position ?? "none"] ?? POSITION_CLASSES.none),
107
+ "z-50",
68
108
  className
69
109
  );
70
110
 
@@ -72,20 +112,30 @@ function FloatingActionButton({
72
112
  "rounded-full shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 leading-none",
73
113
  colorClassName || "bg-primary hover:bg-primary/90 text-primary-foreground"
74
114
  );
115
+ const menuButtonBaseClass = cn(
116
+ "bg-secondary hover:bg-secondary/80 text-secondary-foreground",
117
+ "hover:scale-110 hover:shadow-lg hover:ring-2 hover:ring-primary/40",
118
+ menuButtonClassName
119
+ );
120
+ const menuLabelBaseClass = cn(
121
+ "bg-popover text-popover-foreground border border-border",
122
+ menuLabelClassName
123
+ );
75
124
 
76
125
  const shapeClasses =
77
- resolvedShowLabel === "never"
126
+ resolvedShowLabel === "never" || isMenuMode
78
127
  ? "h-14 w-14 inline-flex items-center justify-center gap-0 p-0"
79
128
  : resolvedShowLabel === "always"
80
129
  ? "h-14 inline-flex items-center justify-center gap-0 px-4"
81
- : // hover mode
82
- "group h-14 inline-flex items-center gap-0 justify-center min-w-[3.5rem] px-0 transition-all duration-300 ease-out hover:px-4";
130
+ : "group h-14 inline-flex items-center gap-0 justify-center min-w-[3.5rem] px-0 transition-all duration-300 ease-out hover:px-4";
83
131
 
84
132
  const buttonClasses = cn(baseButton, shapeClasses);
85
133
 
86
134
  const iconClasses = cn(
87
- "h-6 w-6 block shrink-0 pointer-events-none transition-transform duration-200 ease-out",
88
- resolvedShowLabel === "hover" ? "group-hover:scale-110 group-hover:rotate-3" : ""
135
+ "h-6 w-6 block shrink-0 pointer-events-none",
136
+ !isMenuMode && resolvedShowLabel === "hover"
137
+ ? "group-hover:scale-110 group-hover:rotate-3 transition-transform duration-200"
138
+ : ""
89
139
  );
90
140
 
91
141
  const labelClasses = cn(
@@ -97,6 +147,151 @@ function FloatingActionButton({
97
147
  : "sr-only"
98
148
  );
99
149
 
150
+ // ── MENU (speed-dial) MODE ───────────────────────────────────────────────
151
+ if (isMenuMode) {
152
+ const top = isTopPosition(position);
153
+ const labelOnLeft = labelSide(position) === "left";
154
+ const columnAlignClass = labelOnLeft ? "items-end" : "items-start";
155
+ const actionListMarginClass = top ? "mt-3" : "mb-3";
156
+ const hiddenTranslateYClass = top ? "-translate-y-4" : "translate-y-4";
157
+
158
+ const getStaggerDelay = (idx) => {
159
+ if (menuOpen) {
160
+ // Bloom from the trigger button outwards.
161
+ // Top menus: idx=0 is nearest; bottom menus: idx=last is nearest.
162
+ return top ? idx * 40 : (actions.length - 1 - idx) * 40;
163
+ }
164
+
165
+ // Reverse order while collapsing for a cleaner close animation.
166
+ return top ? (actions.length - 1 - idx) * 40 : idx * 40;
167
+ };
168
+
169
+ const renderActionList = () => (
170
+ <div
171
+ className={cn("flex gap-3 flex-col", actionListMarginClass, columnAlignClass)}
172
+ aria-hidden={!menuOpen}
173
+ >
174
+ {actions.map((action, idx) => {
175
+ const ActionIcon = action.icon;
176
+ const staggerDelay = getStaggerDelay(idx);
177
+
178
+ return (
179
+ <div
180
+ key={idx}
181
+ className={cn(
182
+ "flex items-center gap-3",
183
+ labelOnLeft ? "flex-row" : "flex-row-reverse"
184
+ )}
185
+ style={{ transitionDelay: `${staggerDelay}ms` }}
186
+ >
187
+ <span
188
+ className={cn(
189
+ "px-3 py-1.5 rounded-full text-sm font-medium whitespace-nowrap",
190
+ "shadow-md",
191
+ menuLabelBaseClass,
192
+ "transition-all duration-200 ease-out",
193
+ menuOpen
194
+ ? "opacity-100 translate-x-0 scale-100"
195
+ : labelOnLeft
196
+ ? "opacity-0 translate-x-3 scale-95 pointer-events-none"
197
+ : "opacity-0 -translate-x-3 scale-95 pointer-events-none"
198
+ )}
199
+ style={{ transitionDelay: `${staggerDelay}ms` }}
200
+ >
201
+ {action.label}
202
+ </span>
203
+
204
+ <button
205
+ type="button"
206
+ onClick={() => {
207
+ action.onClick?.();
208
+ setMenuOpen(false);
209
+ }}
210
+ disabled={disabled}
211
+ tabIndex={menuOpen ? 0 : -1}
212
+ aria-label={action.label}
213
+ title={action.label}
214
+ className={cn(
215
+ "group h-12 w-12 rounded-full shadow-md flex items-center justify-center shrink-0",
216
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
217
+ "transition-all duration-200 ease-out",
218
+ menuButtonBaseClass,
219
+ menuOpen
220
+ ? "opacity-100 scale-100 translate-y-0"
221
+ : cn("opacity-0 scale-75 pointer-events-none", hiddenTranslateYClass)
222
+ )}
223
+ style={{ transitionDelay: `${staggerDelay}ms` }}
224
+ >
225
+ {ActionIcon ? (
226
+ <ActionIcon
227
+ className="h-5 w-5 pointer-events-none transition-transform duration-200 group-hover:scale-110 group-hover:rotate-6"
228
+ aria-hidden="true"
229
+ />
230
+ ) : null}
231
+ </button>
232
+ </div>
233
+ );
234
+ })}
235
+ </div>
236
+ );
237
+
238
+ const renderTriggerButton = () => (
239
+ <Button
240
+ type="button"
241
+ data-slot="floating-action-button"
242
+ className={cn(buttonClasses, "transition-transform duration-200", menuOpen ? "rotate-0" : "")}
243
+ size="icon"
244
+ aria-label={menuAriaLabel ?? ariaLabel ?? "Open menu"}
245
+ aria-expanded={menuOpen}
246
+ aria-haspopup="true"
247
+ title={menuAriaLabel ?? ariaLabel ?? "Open menu"}
248
+ disabled={disabled}
249
+ onClick={() => setMenuOpen((v) => !v)}
250
+ {...props}
251
+ >
252
+ <span
253
+ className={cn(
254
+ "transition-all duration-300 absolute",
255
+ menuOpen ? "opacity-100 rotate-0 scale-100" : "opacity-0 rotate-90 scale-75"
256
+ )}
257
+ aria-hidden="true"
258
+ >
259
+ <X className="h-6 w-6" />
260
+ </span>
261
+ <span
262
+ className={cn(
263
+ "transition-all duration-300",
264
+ menuOpen ? "opacity-0 -rotate-90 scale-75" : "opacity-100 rotate-0 scale-100"
265
+ )}
266
+ aria-hidden="true"
267
+ >
268
+ {Icon ? <Icon className="h-6 w-6 block shrink-0" /> : null}
269
+ </span>
270
+ </Button>
271
+ );
272
+
273
+ return (
274
+ <div
275
+ ref={containerRef}
276
+ className={cn(containerClasses, "flex flex-col gap-0", columnAlignClass)}
277
+ data-testid="fab-container"
278
+ >
279
+ {top ? (
280
+ <>
281
+ {renderTriggerButton()}
282
+ {renderActionList()}
283
+ </>
284
+ ) : (
285
+ <>
286
+ {renderActionList()}
287
+ {renderTriggerButton()}
288
+ </>
289
+ )}
290
+ </div>
291
+ );
292
+ }
293
+
294
+ // ── SINGLE ACTION MODE ───────────────────────────────────────────────────
100
295
  return (
101
296
  <div className={containerClasses} data-testid="fab-container">
102
297
  <Button
package/src/index.d.ts CHANGED
@@ -123,6 +123,11 @@ export interface FloatingActionButtonProps {
123
123
  icon?: React.ComponentType<any>
124
124
  label?: string
125
125
  onClick?: () => void
126
+ actions?: Array<{
127
+ icon?: React.ComponentType<any>
128
+ label: string
129
+ onClick?: () => void
130
+ }>
126
131
  showLabel?: 'hover' | 'always' | 'never'
127
132
  // @deprecated use showLabel instead. If provided: true => 'hover', false => 'never'
128
133
  expandOnHover?: boolean
@@ -130,8 +135,11 @@ export interface FloatingActionButtonProps {
130
135
  className?: string
131
136
  positionClassName?: string
132
137
  colorClassName?: string
138
+ menuButtonClassName?: string
139
+ menuLabelClassName?: string
133
140
  disabled?: boolean
134
141
  ariaLabel?: string
142
+ menuAriaLabel?: string
135
143
  }
136
144
  export const FloatingActionButton: React.ComponentType<FloatingActionButtonProps>
137
145