@snapdragonsnursery/react-components 1.10.0 → 1.12.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 +1 -1
- package/src/components/app-sidebar.jsx +6 -0
- package/src/components/theme-mode-toggle.jsx +1 -1
- package/src/components/ui/floating-action-button.jsx +126 -0
- package/src/components/ui/floating-action-button.test.jsx +46 -0
- package/src/components/ui/sidebar.jsx +1 -1
- package/src/index.d.ts +33 -1
- package/src/index.js +3 -0
package/package.json
CHANGED
|
@@ -173,6 +173,7 @@ export function AppSidebar({
|
|
|
173
173
|
navItems,
|
|
174
174
|
projects,
|
|
175
175
|
user,
|
|
176
|
+
version,
|
|
176
177
|
...props
|
|
177
178
|
}) {
|
|
178
179
|
return (
|
|
@@ -198,6 +199,11 @@ export function AppSidebar({
|
|
|
198
199
|
<SidebarTrigger aria-label="Toggle sidebar" />
|
|
199
200
|
</div>
|
|
200
201
|
<NavUser user={user ?? data.user} />
|
|
202
|
+
{version ? (
|
|
203
|
+
<div className="mt-1 text-xs text-muted-foreground">
|
|
204
|
+
<a href="/changelog" title="View changelog" className="hover:underline">v{version}</a>
|
|
205
|
+
</div>
|
|
206
|
+
) : null}
|
|
201
207
|
</SidebarFooter>
|
|
202
208
|
<SidebarRail />
|
|
203
209
|
</Sidebar>
|
|
@@ -51,7 +51,7 @@ export function ThemeModeToggle() {
|
|
|
51
51
|
return (
|
|
52
52
|
<DropdownMenu>
|
|
53
53
|
<DropdownMenuTrigger asChild>
|
|
54
|
-
<Button variant="ghost" size="icon" className="h-7 w-7" aria-label={`Theme: ${mode}`}>
|
|
54
|
+
<Button variant="ghost" size="icon" className="h-7 w-7 p-0" aria-label={`Theme: ${mode}`}>
|
|
55
55
|
{currentIcon}
|
|
56
56
|
<span className="sr-only">Toggle theme</span>
|
|
57
57
|
</Button>
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// FloatingActionButton.jsx
|
|
2
|
+
// ---------------------------------
|
|
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
|
|
8
|
+
//
|
|
9
|
+
// Example usage:
|
|
10
|
+
//
|
|
11
|
+
// import { FloatingActionButton } from "@snapdragonsnursery/react-components";
|
|
12
|
+
// import { Plus } from "lucide-react";
|
|
13
|
+
//
|
|
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
|
+
// }
|
|
25
|
+
//
|
|
26
|
+
// Notes:
|
|
27
|
+
// - Provide ariaLabel if label is omitted.
|
|
28
|
+
// - Uses Tailwind CSS + shadcn design tokens (bg-primary, text-primary-foreground, etc.).
|
|
29
|
+
|
|
30
|
+
import * as React from "react";
|
|
31
|
+
import { cn } from "../../lib/utils";
|
|
32
|
+
import { Button } from "./button.jsx";
|
|
33
|
+
|
|
34
|
+
const POSITION_CLASSES = {
|
|
35
|
+
"bottom-right": "fixed bottom-5 right-5",
|
|
36
|
+
"bottom-left": "fixed bottom-5 left-5",
|
|
37
|
+
"top-right": "fixed top-5 right-5",
|
|
38
|
+
"top-left": "fixed top-5 left-5",
|
|
39
|
+
none: "",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function FloatingActionButton({
|
|
43
|
+
icon: Icon,
|
|
44
|
+
label = "",
|
|
45
|
+
onClick,
|
|
46
|
+
showLabel,
|
|
47
|
+
// Deprecated: expandOnHover kept for backwards compatibility. If provided and showLabel is undefined,
|
|
48
|
+
// we map: true -> 'hover', false -> 'never'.
|
|
49
|
+
expandOnHover,
|
|
50
|
+
position = "bottom-right",
|
|
51
|
+
className,
|
|
52
|
+
disabled = false,
|
|
53
|
+
ariaLabel,
|
|
54
|
+
positionClassName,
|
|
55
|
+
colorClassName,
|
|
56
|
+
...props
|
|
57
|
+
}) {
|
|
58
|
+
const resolvedShowLabel = React.useMemo(() => {
|
|
59
|
+
if (showLabel) return showLabel;
|
|
60
|
+
if (typeof expandOnHover === "boolean") {
|
|
61
|
+
return expandOnHover ? "hover" : "never";
|
|
62
|
+
}
|
|
63
|
+
return label ? "hover" : "never";
|
|
64
|
+
}, [showLabel, expandOnHover, label]);
|
|
65
|
+
|
|
66
|
+
const containerClasses = cn(
|
|
67
|
+
positionClassName || (POSITION_CLASSES[position ?? "none"] ?? POSITION_CLASSES.none),
|
|
68
|
+
className
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const baseButton = cn(
|
|
72
|
+
"rounded-full shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 leading-none",
|
|
73
|
+
colorClassName || "bg-primary hover:bg-primary/90 text-primary-foreground"
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const shapeClasses =
|
|
77
|
+
resolvedShowLabel === "never"
|
|
78
|
+
? "h-14 w-14 inline-flex items-center justify-center gap-0 p-0"
|
|
79
|
+
: resolvedShowLabel === "always"
|
|
80
|
+
? "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";
|
|
83
|
+
|
|
84
|
+
const buttonClasses = cn(baseButton, shapeClasses);
|
|
85
|
+
|
|
86
|
+
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" : ""
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const labelClasses = cn(
|
|
92
|
+
"leading-none whitespace-nowrap",
|
|
93
|
+
resolvedShowLabel === "always"
|
|
94
|
+
? "ml-2"
|
|
95
|
+
: resolvedShowLabel === "hover"
|
|
96
|
+
? "max-w-0 overflow-hidden opacity-0 scale-95 group-hover:max-w-[14rem] group-hover:opacity-100 group-hover:scale-100 group-hover:ml-2 transition-all duration-300 ease-out"
|
|
97
|
+
: "sr-only"
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div className={containerClasses} data-testid="fab-container">
|
|
102
|
+
<Button
|
|
103
|
+
type="button"
|
|
104
|
+
data-slot="floating-action-button"
|
|
105
|
+
className={buttonClasses}
|
|
106
|
+
size={resolvedShowLabel === "never" ? "icon" : undefined}
|
|
107
|
+
aria-label={ariaLabel || label || "Action"}
|
|
108
|
+
title={ariaLabel || label || "Action"}
|
|
109
|
+
onClick={onClick}
|
|
110
|
+
disabled={disabled}
|
|
111
|
+
{...props}
|
|
112
|
+
>
|
|
113
|
+
{Icon ? <Icon className={iconClasses} aria-hidden="true" /> : null}
|
|
114
|
+
{label ? (
|
|
115
|
+
<span className={labelClasses} data-testid={resolvedShowLabel !== "never" ? "fab-label" : undefined}>
|
|
116
|
+
{label}
|
|
117
|
+
</span>
|
|
118
|
+
) : null}
|
|
119
|
+
</Button>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export default FloatingActionButton;
|
|
125
|
+
|
|
126
|
+
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Basic tests for FloatingActionButton
|
|
2
|
+
// - Renders icon and label behaviour depending on expandOnHover
|
|
3
|
+
|
|
4
|
+
import React from 'react'
|
|
5
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
6
|
+
import FloatingActionButton from './floating-action-button'
|
|
7
|
+
|
|
8
|
+
function DummyIcon(props) {
|
|
9
|
+
return <svg aria-hidden="true" data-testid="dummy-icon" {...props} />
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('FloatingActionButton (library)', () => {
|
|
13
|
+
it('renders with icon', () => {
|
|
14
|
+
render(<FloatingActionButton icon={DummyIcon} ariaLabel="Action" />)
|
|
15
|
+
expect(screen.getByTestId('dummy-icon')).toBeInTheDocument()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('expands label on hover when showLabel="hover"', () => {
|
|
19
|
+
render(<FloatingActionButton icon={DummyIcon} label="Create" showLabel="hover" />)
|
|
20
|
+
// Label exists in DOM (hidden initially via CSS)
|
|
21
|
+
const label = screen.getByTestId('fab-label')
|
|
22
|
+
expect(label).toBeInTheDocument()
|
|
23
|
+
// Simulate hover on container
|
|
24
|
+
const container = screen.getByTestId('fab-container')
|
|
25
|
+
fireEvent.mouseOver(container)
|
|
26
|
+
// Ensure label text is present
|
|
27
|
+
expect(label).toHaveTextContent('Create')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('hides label visually when showLabel="never"', () => {
|
|
31
|
+
render(<FloatingActionButton icon={DummyIcon} label="Search" showLabel="never" ariaLabel="Search" />)
|
|
32
|
+
// No test id for label since not rendered
|
|
33
|
+
const button = screen.getByRole('button', { name: /search/i })
|
|
34
|
+
expect(button).toBeInTheDocument()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('calls onClick when clicked', () => {
|
|
38
|
+
const onClick = jest.fn()
|
|
39
|
+
render(<FloatingActionButton icon={DummyIcon} ariaLabel="Action" onClick={onClick} />)
|
|
40
|
+
const button = screen.getByRole('button', { name: /action/i })
|
|
41
|
+
fireEvent.click(button)
|
|
42
|
+
expect(onClick).toHaveBeenCalledTimes(1)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
|
package/src/index.d.ts
CHANGED
|
@@ -66,7 +66,22 @@ export function configureTelemetry(...args: any[]): any
|
|
|
66
66
|
|
|
67
67
|
|
|
68
68
|
// Sidebar + UI exports
|
|
69
|
-
export
|
|
69
|
+
export interface AppSidebarProps {
|
|
70
|
+
sites?: any
|
|
71
|
+
activeSiteId?: any
|
|
72
|
+
onSiteChange?: (item: any) => void
|
|
73
|
+
sitesLoading?: boolean
|
|
74
|
+
rooms?: any
|
|
75
|
+
activeRoomId?: any
|
|
76
|
+
onRoomChange?: (item: any) => void
|
|
77
|
+
roomsLoading?: boolean
|
|
78
|
+
roomBaseColor?: string
|
|
79
|
+
navItems?: any
|
|
80
|
+
projects?: any
|
|
81
|
+
user?: any
|
|
82
|
+
version?: string
|
|
83
|
+
}
|
|
84
|
+
export const AppSidebar: React.ComponentType<AppSidebarProps>
|
|
70
85
|
export const Sidebar: React.ComponentType<any>
|
|
71
86
|
export const SidebarContent: React.ComponentType<any>
|
|
72
87
|
export const SidebarFooter: React.ComponentType<any>
|
|
@@ -101,6 +116,23 @@ export const BreadcrumbPage: React.ComponentType<any>
|
|
|
101
116
|
export const BreadcrumbSeparator: React.ComponentType<any>
|
|
102
117
|
export const BreadcrumbEllipsis: React.ComponentType<any>
|
|
103
118
|
|
|
119
|
+
// Floating Action Button
|
|
120
|
+
export interface FloatingActionButtonProps {
|
|
121
|
+
icon?: React.ComponentType<any>
|
|
122
|
+
label?: string
|
|
123
|
+
onClick?: () => void
|
|
124
|
+
showLabel?: 'hover' | 'always' | 'never'
|
|
125
|
+
// @deprecated use showLabel instead. If provided: true => 'hover', false => 'never'
|
|
126
|
+
expandOnHover?: boolean
|
|
127
|
+
position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' | 'none'
|
|
128
|
+
className?: string
|
|
129
|
+
positionClassName?: string
|
|
130
|
+
colorClassName?: string
|
|
131
|
+
disabled?: boolean
|
|
132
|
+
ariaLabel?: string
|
|
133
|
+
}
|
|
134
|
+
export const FloatingActionButton: React.ComponentType<FloatingActionButtonProps>
|
|
135
|
+
|
|
104
136
|
// Switchers
|
|
105
137
|
export interface SwitcherItem {
|
|
106
138
|
id?: string | number
|