@stevederico/skateboard-ui 4.10.0 → 4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # CHANGELOG
2
2
 
3
+ 4.12.0
4
+
5
+ Fix drawer dark-mode text
6
+ Fix auth redirect route
7
+
8
+ 4.11.0
9
+
10
+ Fix context-menu and menubar mouse clicks
11
+ Fix popover focus-into-content on open
12
+ Fix calendar single-select first-click crash
13
+ Share dismiss layer across nested overlays
14
+ Slider pointercancel cleanup and controlled drag
15
+ Fix roving tabindex (radio, tabs, calendar)
16
+ Avatar reconciles cached images
17
+ Command palette single-pass filter and a11y name
18
+ Add aria-expanded to sheet/drawer/alert-dialog
19
+ Guard checkout and subscription fields
20
+ TabBar active match by path segment
21
+ Remove banned type assertions and dead code
22
+
3
23
  4.10.0
4
24
 
5
25
  Fix dropdown menu item clicks
package/dist/App.js CHANGED
@@ -41,9 +41,7 @@ function AuthRedirect() {
41
41
  }
42
42
  function App({ constants, appRoutes, defaultRoute, landingPage, overrides = {} }) {
43
43
  const location = useLocation();
44
- useEffect(() => {
45
- document.title = constants.appName;
46
- }, [constants.appName]);
44
+ // document.title is set by useAppSetup (on navigation) — single source of truth.
47
45
  useAppSetup(location);
48
46
  const LayoutComponent = overrides.layout || Layout;
49
47
  const SettingsComponent = overrides.settings || SettingsView;
@@ -183,10 +183,31 @@ export function DayPicker({ mode = 'single', selected, onSelect, month: controll
183
183
  const WeekNumber = components.WeekNumber || DefaultWeekNumber;
184
184
  const today = useMemo(() => startOfDay(new Date()), []);
185
185
  const grid = useMemo(() => buildGrid(displayMonth, weekStartsOn), [displayMonth, weekStartsOn]);
186
+ // Fallback tabbable cell: keep the grid keyboard-reachable when no day is
187
+ // focused and "today" isn't in the displayed month. Prefer the selected day
188
+ // (when it falls in this month), else the first non-disabled day of the month.
189
+ const fallbackTabbable = useMemo(() => {
190
+ if (focusedDay)
191
+ return null;
192
+ const todayInMonth = isSameMonth(today, displayMonth);
193
+ if (todayInMonth)
194
+ return null;
195
+ if (selected instanceof Date && isSameMonth(selected, displayMonth)) {
196
+ return startOfDay(selected);
197
+ }
198
+ for (const week of grid) {
199
+ for (const date of week) {
200
+ if (isSameMonth(date, displayMonth) && !isDisabled(date, disabled))
201
+ return date;
202
+ }
203
+ }
204
+ return null;
205
+ }, [focusedDay, today, displayMonth, selected, grid, disabled]);
186
206
  // Selection handler
187
207
  const handleSelect = useCallback((date) => {
188
208
  if (mode === 'single') {
189
- onSelect?.(sameDay(selected, date) ? undefined : date);
209
+ const isSame = selected instanceof Date && sameDay(selected, date);
210
+ onSelect?.(isSame ? undefined : date);
190
211
  return;
191
212
  }
192
213
  if (mode === 'range') {
@@ -301,6 +322,9 @@ export function DayPicker({ mode = 'single', selected, onSelect, month: controll
301
322
  focused
302
323
  };
303
324
  const dayObj = { date };
304
- return (_jsx("td", { className: dayClass, "data-selected": modSel ? 'true' : undefined, "data-disabled": dis ? 'true' : undefined, "data-outside": outside ? 'true' : undefined, "data-today": tod ? 'true' : undefined, children: _jsx(DayButton, { day: dayObj, modifiers: modifiers, onClick: () => !dis && handleSelect(date), onFocus: () => handleDayFocus(date), tabIndex: focused || (!focusedDay && sameDay(date, today)) ? 0 : -1, "aria-selected": modSel, "aria-disabled": dis, "aria-current": tod ? 'date' : undefined, className: "", children: date.getDate() }) }, date.toISOString()));
325
+ const isTabbable = Boolean(focused ||
326
+ (!focusedDay && isSameMonth(today, displayMonth) && sameDay(date, today)) ||
327
+ (fallbackTabbable && sameDay(date, fallbackTabbable)));
328
+ return (_jsx("td", { role: "gridcell", className: dayClass, "aria-selected": modSel, "data-selected": modSel ? 'true' : undefined, "data-disabled": dis ? 'true' : undefined, "data-outside": outside ? 'true' : undefined, "data-today": tod ? 'true' : undefined, children: _jsx(DayButton, { day: dayObj, modifiers: modifiers, onClick: () => !dis && handleSelect(date), onFocus: () => handleDayFocus(date), tabIndex: isTabbable ? 0 : -1, "aria-pressed": modSel, "aria-disabled": dis, "aria-current": tod ? 'date' : undefined, className: "", children: date.getDate() }) }, date.toISOString()));
305
329
  }) }, wi))) })] })] }) }) }));
306
330
  }
@@ -1,8 +1,7 @@
1
1
  import type * as React from 'react';
2
- export interface CommandProps {
2
+ export interface CommandProps extends React.ComponentPropsWithoutRef<'div'> {
3
3
  className?: string;
4
4
  children?: React.ReactNode;
5
- [key: string]: any;
6
5
  }
7
6
  export declare function Command({ className, children, ...props }: CommandProps): React.JSX.Element;
8
7
  export declare namespace Command {
@@ -13,49 +12,42 @@ export declare namespace Command {
13
12
  var Item: typeof CommandItem;
14
13
  var Separator: typeof CommandSeparator;
15
14
  }
16
- export interface CommandInputProps {
15
+ export interface CommandInputProps extends Omit<React.ComponentPropsWithoutRef<'input'>, 'value' | 'onChange'> {
17
16
  className?: string;
18
17
  placeholder?: string;
19
18
  value?: string;
20
19
  onValueChange?: (value: string) => void;
21
- [key: string]: any;
22
20
  }
23
21
  export declare function CommandInput({ className, placeholder, value: controlledValue, onValueChange, ...props }: CommandInputProps): React.JSX.Element;
24
- export interface CommandListProps {
22
+ export interface CommandListProps extends React.ComponentPropsWithoutRef<'div'> {
25
23
  className?: string;
26
24
  children?: React.ReactNode;
27
- [key: string]: any;
28
25
  }
29
26
  export declare function CommandList({ className, children, ...props }: CommandListProps): React.JSX.Element;
30
- export interface CommandEmptyProps {
27
+ export interface CommandEmptyProps extends React.ComponentPropsWithoutRef<'div'> {
31
28
  className?: string;
32
29
  children?: React.ReactNode;
33
- [key: string]: any;
34
30
  }
35
31
  export declare function CommandEmpty({ className, children, ...props }: CommandEmptyProps): React.JSX.Element | null;
36
- export interface CommandGroupProps {
32
+ export interface CommandGroupProps extends React.ComponentPropsWithoutRef<'div'> {
37
33
  className?: string;
38
34
  heading?: React.ReactNode;
39
35
  children?: React.ReactNode;
40
- [key: string]: any;
41
36
  }
42
37
  export declare function CommandGroup({ className, heading, children, ...props }: CommandGroupProps): React.JSX.Element;
43
- export interface CommandItemProps {
38
+ export interface CommandItemProps extends Omit<React.ComponentPropsWithoutRef<'div'>, 'onSelect'> {
44
39
  className?: string;
45
40
  value?: string;
46
41
  onSelect?: (value: string) => void;
47
42
  disabled?: boolean;
48
43
  children?: React.ReactNode;
49
- [key: string]: any;
50
44
  }
51
45
  export declare function CommandItem({ className, value, onSelect, disabled, children, ...props }: CommandItemProps): React.JSX.Element | null;
52
- export interface CommandSeparatorProps {
46
+ export interface CommandSeparatorProps extends React.ComponentPropsWithoutRef<'div'> {
53
47
  className?: string;
54
- [key: string]: any;
55
48
  }
56
49
  export declare function CommandSeparator({ className, ...props }: CommandSeparatorProps): React.JSX.Element;
57
- export interface CommandShortcutProps {
50
+ export interface CommandShortcutProps extends React.ComponentPropsWithoutRef<'span'> {
58
51
  className?: string;
59
- [key: string]: any;
60
52
  }
61
53
  export declare function CommandShortcut({ className, ...props }: CommandShortcutProps): React.JSX.Element;
@@ -14,14 +14,22 @@ export function Command({ className, children, ...props }) {
14
14
  // input's aria-controls / aria-activedescendant can reference them.
15
15
  const listboxId = useId();
16
16
  const itemsRef = useRef(new Map()); // id -> { value, onSelect, disabled }
17
+ // Bumped on every register/unregister so the visible-id memo (which reads the
18
+ // non-reactive itemsRef) recomputes when the registered item set changes.
19
+ const [registrationVersion, setRegistrationVersion] = useState(0);
17
20
  const register = useCallback((id, meta) => {
18
21
  itemsRef.current.set(id, meta);
22
+ setRegistrationVersion((v) => v + 1);
19
23
  }, []);
20
24
  const unregister = useCallback((id) => {
21
25
  itemsRef.current.delete(id);
26
+ setRegistrationVersion((v) => v + 1);
22
27
  setSelectedId((cur) => (cur === id ? null : cur));
23
28
  }, []);
24
- const visibleIds = useCallback(() => {
29
+ // Compute the ordered visible ids and the membership Set once per render,
30
+ // memoized on the filter and the registered item set. Items can then check
31
+ // visibility in O(1) instead of rescanning the whole list each render.
32
+ const visibleIds = useMemo(() => {
25
33
  const f = filter.trim().toLowerCase();
26
34
  const visible = [];
27
35
  for (const [id, meta] of itemsRef.current) {
@@ -31,24 +39,24 @@ export function Command({ className, children, ...props }) {
31
39
  visible.push(id);
32
40
  }
33
41
  return visible;
34
- }, [filter]);
35
- // Keep selection valid when filter changes
42
+ }, [filter, registrationVersion]);
43
+ const visibleSet = useMemo(() => new Set(visibleIds), [visibleIds]);
44
+ const visibleCount = visibleSet.size;
45
+ // Keep selection valid when the visible set changes (filter input or
46
+ // registration), driven by the reactive derived ids rather than the ref.
36
47
  useEffect(() => {
37
- const visible = visibleIds();
38
- if (visible.length === 0) {
48
+ if (visibleIds.length === 0) {
39
49
  setSelectedId(null);
40
50
  return;
41
51
  }
42
- if (!selectedId || !visible.includes(selectedId))
43
- setSelectedId(visible[0]);
44
- }, [filter, visibleIds, selectedId]);
52
+ setSelectedId((cur) => (cur && visibleSet.has(cur) ? cur : visibleIds[0]));
53
+ }, [visibleIds, visibleSet]);
45
54
  const move = useCallback((dir) => {
46
- const visible = visibleIds();
47
- if (visible.length === 0)
55
+ if (visibleIds.length === 0)
48
56
  return;
49
- const idx = selectedId ? visible.indexOf(selectedId) : -1;
50
- const next = (idx + dir + visible.length) % visible.length;
51
- setSelectedId(visible[next]);
57
+ const idx = selectedId ? visibleIds.indexOf(selectedId) : -1;
58
+ const next = (idx + dir + visibleIds.length) % visibleIds.length;
59
+ setSelectedId(visibleIds[next]);
52
60
  }, [selectedId, visibleIds]);
53
61
  const triggerSelected = useCallback(() => {
54
62
  if (!selectedId)
@@ -56,9 +64,7 @@ export function Command({ className, children, ...props }) {
56
64
  const meta = itemsRef.current.get(selectedId);
57
65
  meta?.onSelect?.(meta.value);
58
66
  }, [selectedId]);
59
- const isVisible = useCallback((id) => visibleIds().includes(id), [visibleIds]);
60
- const visibleCount = visibleIds().length;
61
- const value = useMemo(() => ({ filter, setFilter, selectedId, setSelectedId, listboxId, register, unregister, isVisible, visibleCount, move, triggerSelected }), [filter, selectedId, listboxId, register, unregister, isVisible, visibleCount, move, triggerSelected]);
67
+ const value = useMemo(() => ({ filter, setFilter, selectedId, setSelectedId, listboxId, register, unregister, visibleSet, visibleCount, move, triggerSelected }), [filter, selectedId, listboxId, register, unregister, visibleSet, visibleCount, move, triggerSelected]);
62
68
  return (_jsx(CommandContext.Provider, { value: value, children: _jsx("div", { className: className, ...props, children: children }) }));
63
69
  }
64
70
  export function CommandInput({ className, placeholder, value: controlledValue, onValueChange, ...props }) {
@@ -93,7 +99,7 @@ export function CommandInput({ className, placeholder, value: controlledValue, o
93
99
  // The input is the combobox. Real DOM focus stays here during arrow
94
100
  // navigation; only aria-activedescendant moves so screen readers announce
95
101
  // the active option (WAI-ARIA combobox + activedescendant pattern).
96
- _jsx("input", { role: "combobox", "aria-autocomplete": "list", "aria-expanded": isExpanded, "aria-controls": listboxId, "aria-activedescendant": activeDescendant, autoComplete: "off", autoCorrect: "off", spellCheck: "false", value: value, onChange: handleChange, onKeyDown: handleKeyDown, placeholder: placeholder, className: className, ...props }));
102
+ _jsx("input", { role: "combobox", "aria-autocomplete": "list", "aria-expanded": isExpanded, "aria-controls": listboxId, "aria-activedescendant": activeDescendant, "aria-label": "Search", autoComplete: "off", autoCorrect: "off", spellCheck: "false", value: value, onChange: handleChange, onKeyDown: handleKeyDown, placeholder: placeholder, className: className, ...props }));
97
103
  }
98
104
  export function CommandList({ className, children, ...props }) {
99
105
  const { listboxId } = useCommand();
@@ -113,7 +119,7 @@ export function CommandItem({ className, value, onSelect, disabled = false, chil
113
119
  // This id is both the value tracked in selectedId and the option's DOM id,
114
120
  // which is what the input's aria-activedescendant references.
115
121
  const id = useId();
116
- const { register, unregister, selectedId, setSelectedId, isVisible } = useCommand();
122
+ const { register, unregister, selectedId, setSelectedId, visibleSet } = useCommand();
117
123
  const optionRef = useRef(null);
118
124
  // Resolve a usable value string — fall back to children text if no value prop
119
125
  const resolvedValue = value ?? (typeof children === 'string' ? children : id);
@@ -128,7 +134,7 @@ export function CommandItem({ className, value, onSelect, disabled = false, chil
128
134
  if (isSelected)
129
135
  optionRef.current?.scrollIntoView({ block: 'nearest' });
130
136
  }, [isSelected]);
131
- if (!isVisible(id))
137
+ if (!visibleSet.has(id))
132
138
  return null;
133
139
  const handleClick = () => {
134
140
  if (disabled)
@@ -90,13 +90,15 @@ export function ContextProvider({ children, constants }) {
90
90
  const appName = constants.appName || 'skateboard';
91
91
  return `${appName.toLowerCase().replace(/\s+/g, '-')}_user`;
92
92
  };
93
+ const isUser = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
93
94
  const getInitialUser = () => {
94
95
  try {
95
96
  const storageKey = getStorageKey();
96
97
  const storedUser = safeLSGetItem(storageKey);
97
98
  if (!storedUser || storedUser === "undefined")
98
99
  return null;
99
- return JSON.parse(storedUser);
100
+ const parsed = JSON.parse(storedUser);
101
+ return isUser(parsed) ? parsed : null;
100
102
  }
101
103
  catch (e) {
102
104
  console.error('Error parsing user data:', e instanceof Error ? e.message : String(e));
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from 'react';
1
+ import { useEffect, useRef, useState } from 'react';
2
2
  import { useInRouterContext, useNavigate } from 'react-router';
3
3
  import { getDispatch } from './Context.js';
4
4
  // Constants will be initialized by the app shell
@@ -389,8 +389,13 @@ export async function showManage(stripeID) {
389
389
  export async function showCheckout(email, productIndex = 0) {
390
390
  try {
391
391
  const csrfToken = getCSRFToken();
392
+ const product = getConstants().stripeProducts?.[productIndex];
393
+ if (!product) {
394
+ console.error("No stripe product at index", productIndex);
395
+ return false;
396
+ }
392
397
  const params = {
393
- lookup_key: getConstants().stripeProducts[productIndex].lookup_key,
398
+ lookup_key: product.lookup_key,
394
399
  email: email
395
400
  };
396
401
  const uri = `${getBackendURL()}/checkout`;
@@ -853,11 +858,15 @@ export function useListData(endpoint, sortFn = null) {
853
858
  const [data, setData] = useState([]);
854
859
  const [loading, setLoading] = useState(true);
855
860
  const [error, setError] = useState(null);
861
+ // Keep sortFn in a ref so an inline sort function doesn't retrigger the fetch effect every render
862
+ const sortFnRef = useRef(sortFn);
863
+ sortFnRef.current = sortFn;
856
864
  const fetchData = async (signal) => {
857
865
  setLoading(true);
858
866
  try {
859
867
  const result = await apiRequest(endpoint, { signal });
860
- const sorted = sortFn ? result.sort(sortFn) : result;
868
+ const sort = sortFnRef.current;
869
+ const sorted = sort ? result.sort(sort) : result;
861
870
  setData(sorted);
862
871
  setError(null);
863
872
  }
@@ -875,7 +884,7 @@ export function useListData(endpoint, sortFn = null) {
875
884
  const controller = new AbortController();
876
885
  fetchData(controller.signal);
877
886
  return () => controller.abort();
878
- }, [endpoint, sortFn]);
887
+ }, [endpoint]);
879
888
  return { data, loading, error, refetch: () => fetchData() };
880
889
  }
881
890
  /**
@@ -8,6 +8,7 @@ export default function TabBar({ className, ...props }) {
8
8
  const location = useLocation();
9
9
  const { state } = getState();
10
10
  const constants = state.constants;
11
+ const currentPage = (location.pathname.split("/")[2] || "").toLowerCase();
11
12
  const tabs = [
12
13
  ...(constants?.pages || []).map((item) => ({
13
14
  title: item.title,
@@ -18,7 +19,7 @@ export default function TabBar({ className, ...props }) {
18
19
  { title: 'Settings', icon: 'settings', to: '/app/settings', match: 'settings' },
19
20
  ];
20
21
  return (_jsx("nav", { className: cn("fixed md:hidden bottom-0 inset-x-0 bg-background border-t border-border z-50", className), role: "navigation", "aria-label": "Main navigation", ...props, children: _jsx("div", { className: "flex justify-around items-center pt-2 pb-4", children: tabs.map((tab) => {
21
- const isActive = location.pathname.includes(tab.match);
22
+ const isActive = currentPage === tab.match;
22
23
  return (_jsxs(Link, { to: tab.to, className: cn("flex flex-col items-center gap-0.5 px-3 transition-colors", isActive ? "text-foreground" : "text-muted-foreground"), "aria-label": tab.title, "aria-current": isActive ? 'page' : undefined, children: [_jsx(DynamicIcon, { name: tab.icon, size: 24, strokeWidth: isActive ? 2 : 1.5 }), _jsx("span", { className: "text-xs", children: tab.title })] }, tab.title));
23
24
  }) }) }));
24
25
  }
@@ -51,7 +51,7 @@ export default function LandingView() {
51
51
  const goApp = () => navigate('/app');
52
52
  const navLinks = constants.navLinks || [
53
53
  { label: 'Features', href: '#features' },
54
- ...(constants.stripeProducts?.length > 0 ? [{ label: 'Pricing', href: '#pricing' }] : []),
54
+ ...((constants.stripeProducts?.length ?? 0) > 0 ? [{ label: 'Pricing', href: '#pricing' }] : []),
55
55
  ];
56
56
  const footerLinks = constants.footerLinks || [
57
57
  ...(constants.privacyPolicy ? [PRIVACY_LINK] : []),
@@ -1,3 +1,4 @@
1
+ import type React from 'react';
1
2
  /**
2
3
  * 404 page displayed for unmatched routes.
3
4
  *
@@ -10,8 +11,6 @@
10
11
  *
11
12
  * <Route path="*" element={<NotFound />} />
12
13
  */
13
- export interface NotFoundProps {
14
- className?: string;
15
- [key: string]: any;
14
+ export interface NotFoundProps extends React.HTMLAttributes<HTMLDivElement> {
16
15
  }
17
- export default function NotFound({ className, ...props }: NotFoundProps): import("react").JSX.Element;
16
+ export default function NotFound({ className, ...props }: NotFoundProps): React.JSX.Element;
@@ -30,6 +30,7 @@ export default function SettingsView() {
30
30
  const navigate = useNavigate();
31
31
  const user = state.user;
32
32
  const subscription = user?.subscription;
33
+ const stripeID = subscription?.stripeID;
33
34
  const showAuth = (constants.noLogin === false || typeof constants.noLogin === 'undefined') && user;
34
35
  function signOutClicked() {
35
36
  // Route through the canonical /signout flow (clears the server session and
@@ -40,10 +41,14 @@ export default function SettingsView() {
40
41
  const status = subscription?.status;
41
42
  if (status === null || typeof status === 'undefined')
42
43
  return "Free plan";
44
+ const expires = subscription?.expires;
45
+ const expiresDate = typeof expires === "number"
46
+ ? new Date(expires * 1000).toLocaleDateString('en-US')
47
+ : null;
43
48
  if (status === "active")
44
- return `Renews ${new Date(subscription.expires * 1000).toLocaleDateString('en-US')}`;
49
+ return expiresDate ? `Renews ${expiresDate}` : "Active";
45
50
  if (status === "canceled")
46
- return `Ends ${new Date(subscription.expires * 1000).toLocaleDateString('en-US')}`;
51
+ return expiresDate ? `Ends ${expiresDate}` : "Canceled";
47
52
  return `Plan ${status}`;
48
53
  }
49
54
  return (_jsxs("div", { className: "flex-1", children: [_jsx(Header, { title: "Settings", children: _jsx(ThemeToggle, { iconSize: 20 }) }), _jsxs("div", { className: "flex flex-col items-center p-4 gap-4", children: [!user && constants.noLogin !== true && (_jsx(Card, { className: "w-full max-w-lg", children: _jsxs(CardHeader, { children: [_jsx(CardTitle, { children: "Account" }), _jsx(CardDescription, { children: "Sign in to access your account settings." }), _jsx(CardAction, { children: _jsx(Button, { variant: "outline", size: "sm", onClick: () => {
@@ -53,5 +58,5 @@ export default function SettingsView() {
53
58
  else {
54
59
  navigate('/signin');
55
60
  }
56
- }, children: "Sign In" }) })] }) })), showAuth && (_jsx(Card, { className: "w-full max-w-lg", children: _jsxs(CardHeader, { children: [_jsxs(CardTitle, { className: "flex items-center gap-3", children: [_jsx(Avatar, { children: _jsx(AvatarFallback, { className: "bg-app text-white uppercase font-medium text-sm", children: user.name?.split(' ').map(w => w[0]).join('') || "NA" }) }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center gap-2 capitalize", children: [user.name || "No User", subscription?.status === "active" && _jsx(Badge, { children: "Pro" })] }), _jsx(CardDescription, { className: "font-normal normal-case", children: user.email || "no@user.com" })] })] }), _jsx(CardAction, { children: _jsxs(AlertDialog, { children: [_jsx(AlertDialogTrigger, { render: _jsx(Button, { variant: "outline", size: "sm", children: "Sign Out" }) }), _jsxs(AlertDialogContent, { children: [_jsxs(AlertDialogHeader, { children: [_jsx(AlertDialogTitle, { children: "Sign Out" }), _jsx(AlertDialogDescription, { children: "Are you sure you want to sign out? You'll need to sign in again to access your account." })] }), _jsxs(AlertDialogFooter, { children: [_jsx(AlertDialogCancel, { children: "Cancel" }), _jsx(AlertDialogAction, { onClick: signOutClicked, children: "Sign Out" })] })] })] }) })] }) })), _jsx(Card, { className: "w-full max-w-lg", children: _jsxs(CardHeader, { children: [_jsx(CardTitle, { children: "Support" }), _jsx(CardDescription, { children: "How can we help?" }), _jsx(CardAction, { children: _jsx(Button, { variant: "outline", size: "sm", onClick: () => { window.location.href = `mailto:${constants.companyEmail}`; }, children: "Contact" }) })] }) }), showAuth && (_jsx(Card, { className: "w-full max-w-lg", children: _jsxs(CardHeader, { children: [_jsxs(CardTitle, { className: "flex items-center gap-2", children: ["Billing", subscription?.status === "active" ? (_jsx(Badge, { children: "Active" })) : subscription?.status === "canceled" ? (_jsx(Badge, { variant: "destructive", children: "Canceled" })) : (_jsx(Badge, { variant: "secondary", children: "Free" }))] }), _jsx(CardDescription, { children: getBillingDescription() }), _jsx(CardAction, { children: subscription?.stripeID ? (_jsx(Button, { variant: "outline", size: "sm", onClick: () => { showManage(subscription.stripeID); }, children: "Manage" })) : (_jsx(Button, { variant: "gradient", size: "sm", onClick: () => { showCheckout(user.email); }, children: "Subscribe" })) })] }) }))] }), _jsx("div", { className: "mt-8 text-center pb-24 md:pb-8", children: _jsxs("p", { className: "text-xs text-muted-foreground", children: ["v", constants.version || '0.0.0'] }) })] }));
61
+ }, children: "Sign In" }) })] }) })), showAuth && (_jsx(Card, { className: "w-full max-w-lg", children: _jsxs(CardHeader, { children: [_jsxs(CardTitle, { className: "flex items-center gap-3", children: [_jsx(Avatar, { children: _jsx(AvatarFallback, { className: "bg-app text-white uppercase font-medium text-sm", children: user.name?.split(' ').map(w => w[0]).join('') || "NA" }) }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center gap-2 capitalize", children: [user.name || "No User", subscription?.status === "active" && _jsx(Badge, { children: "Pro" })] }), _jsx(CardDescription, { className: "font-normal normal-case", children: user.email || "no@user.com" })] })] }), _jsx(CardAction, { children: _jsxs(AlertDialog, { children: [_jsx(AlertDialogTrigger, { render: _jsx(Button, { variant: "outline", size: "sm", children: "Sign Out" }) }), _jsxs(AlertDialogContent, { children: [_jsxs(AlertDialogHeader, { children: [_jsx(AlertDialogTitle, { children: "Sign Out" }), _jsx(AlertDialogDescription, { children: "Are you sure you want to sign out? You'll need to sign in again to access your account." })] }), _jsxs(AlertDialogFooter, { children: [_jsx(AlertDialogCancel, { children: "Cancel" }), _jsx(AlertDialogAction, { onClick: signOutClicked, children: "Sign Out" })] })] })] }) })] }) })), _jsx(Card, { className: "w-full max-w-lg", children: _jsxs(CardHeader, { children: [_jsx(CardTitle, { children: "Support" }), _jsx(CardDescription, { children: "How can we help?" }), _jsx(CardAction, { children: _jsx(Button, { variant: "outline", size: "sm", onClick: () => { window.location.href = `mailto:${constants.companyEmail}`; }, children: "Contact" }) })] }) }), showAuth && (_jsx(Card, { className: "w-full max-w-lg", children: _jsxs(CardHeader, { children: [_jsxs(CardTitle, { className: "flex items-center gap-2", children: ["Billing", subscription?.status === "active" ? (_jsx(Badge, { children: "Active" })) : subscription?.status === "canceled" ? (_jsx(Badge, { variant: "destructive", children: "Canceled" })) : (_jsx(Badge, { variant: "secondary", children: "Free" }))] }), _jsx(CardDescription, { children: getBillingDescription() }), _jsx(CardAction, { children: stripeID ? (_jsx(Button, { variant: "outline", size: "sm", onClick: () => { showManage(stripeID); }, children: "Manage" })) : (_jsx(Button, { variant: "gradient", size: "sm", onClick: () => { showCheckout(user.email); }, children: "Subscribe" })) })] }) }))] }), _jsx("div", { className: "mt-8 text-center pb-24 md:pb-8", children: _jsxs("p", { className: "text-xs text-muted-foreground", children: ["v", constants.version || '0.0.0'] }) })] }));
57
62
  }
@@ -8,7 +8,7 @@ import { Card, CardContent, CardHeader } from "../../shadcn/ui/card.js";
8
8
  import { Alert, AlertDescription } from "../../shadcn/ui/alert.js";
9
9
  import DynamicIcon from '../core/DynamicIcon.js';
10
10
  import { getState } from "../core/Context.js";
11
- import { getBackendURL, useSafeNavigate } from '../core/Utilities.js';
11
+ import { getBackendURL, useSafeNavigate, getAppKey } from '../core/Utilities.js';
12
12
  export default function SignInView({ className, embedded = false, onSuccess, onSwitchMode, ...props }) {
13
13
  const { state, dispatch } = getState();
14
14
  const constants = state.constants;
@@ -39,6 +39,12 @@ export default function SignInView({ className, embedded = false, onSuccess, onS
39
39
  });
40
40
  if (response.ok) {
41
41
  const data = await response.json();
42
+ // Save CSRF token to localStorage for isAuthenticated() check
43
+ const csrfCookie = document.cookie.split('; ').find(row => row.startsWith('csrf_token='));
44
+ const csrfToken = csrfCookie ? csrfCookie.split('=')[1] : data.csrfToken;
45
+ if (csrfToken) {
46
+ localStorage.setItem(getAppKey('csrf'), csrfToken);
47
+ }
42
48
  dispatch({ type: 'SET_USER', payload: data });
43
49
  if (embedded && onSuccess) {
44
50
  onSuccess();
@@ -16,6 +16,7 @@ export default function SignUpView({ className, embedded = false, onSuccess, onS
16
16
  const [password, setPassword] = useState('');
17
17
  const [name, setName] = useState('');
18
18
  const [errorMessage, setErrorMessage] = useState('');
19
+ const [isSubmitting, setIsSubmitting] = useState(false);
19
20
  const navigate = useSafeNavigate();
20
21
  const nameInputRef = useRef(null);
21
22
  // Focus the first input on mount
@@ -26,6 +27,8 @@ export default function SignUpView({ className, embedded = false, onSuccess, onS
26
27
  }, []);
27
28
  async function signUpClicked(e) {
28
29
  e.preventDefault();
30
+ if (isSubmitting)
31
+ return;
29
32
  // Client-side password validation (matches backend: 6-72 chars)
30
33
  if (password.length < 6) {
31
34
  setErrorMessage('Password must be at least 6 characters');
@@ -35,6 +38,7 @@ export default function SignUpView({ className, embedded = false, onSuccess, onS
35
38
  setErrorMessage('Password must be 72 characters or less');
36
39
  return;
37
40
  }
41
+ setIsSubmitting(true);
38
42
  try {
39
43
  const response = await fetch(`${getBackendURL()}/signup`, {
40
44
  method: 'POST',
@@ -66,6 +70,9 @@ export default function SignUpView({ className, embedded = false, onSuccess, onS
66
70
  console.error('Signup failed:', error);
67
71
  setErrorMessage('Server Error');
68
72
  }
73
+ finally {
74
+ setIsSubmitting(false);
75
+ }
69
76
  }
70
77
  const formContent = (_jsxs(_Fragment, { children: [errorMessage !== '' && (_jsx(Alert, { variant: "destructive", className: "mb-4", children: _jsx(AlertDescription, { className: "text-center", children: errorMessage }) })), _jsxs("form", { onSubmit: signUpClicked, className: "flex flex-col gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { htmlFor: "name", children: "Name" }), _jsx(Input, { ref: nameInputRef, id: "name", placeholder: "John Doe", autoComplete: "name", required: true, value: name, onChange: (e) => {
71
78
  setName(e.target.value);
@@ -76,7 +83,7 @@ export default function SignUpView({ className, embedded = false, onSuccess, onS
76
83
  } })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { htmlFor: "password", children: "Password" }), _jsx(Input, { id: "password", type: "password", placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022", autoComplete: "new-password", required: true, minLength: 6, maxLength: 72, value: password, onChange: (e) => {
77
84
  setPassword(e.target.value);
78
85
  setErrorMessage('');
79
- } }), _jsx("p", { className: "text-xs text-muted-foreground", children: "Minimum 6 characters" })] }), _jsx(Button, { type: "submit", variant: "gradient", size: "cta", className: "w-full", children: _jsxs("span", { className: "relative z-20 flex items-center justify-center gap-2 drop-shadow-sm", children: [_jsx(DynamicIcon, { name: "sparkles", size: 16, color: "currentColor", strokeWidth: 2, className: "animate-pulse" }), "Sign Up"] }) }), _jsxs("div", { className: "text-center text-sm", children: [_jsx("span", { className: "text-muted-foreground", children: "Already have an account?" }), " ", _jsx(Button, { variant: "link", className: "p-0 h-auto", onClick: (e) => { e.preventDefault(); embedded && onSwitchMode ? onSwitchMode() : navigate('/signin'); }, children: "Sign In" })] })] }), _jsxs("div", { className: "mt-4 text-center text-xs text-muted-foreground", children: ["By registering you agree to our", " ", _jsx("a", { href: "/terms", className: "underline underline-offset-4 hover:text-foreground", children: "Terms of Service" }), ",", " ", _jsx("a", { href: "/eula", className: "underline underline-offset-4 hover:text-foreground", children: "EULA" }), ",", " ", _jsx("a", { href: "/privacy", className: "underline underline-offset-4 hover:text-foreground", children: "Privacy Policy" })] })] }));
86
+ } }), _jsx("p", { className: "text-xs text-muted-foreground", children: "Minimum 6 characters" })] }), _jsx(Button, { type: "submit", variant: "gradient", size: "cta", className: "w-full", disabled: isSubmitting, children: _jsxs("span", { className: "relative z-20 flex items-center justify-center gap-2 drop-shadow-sm", children: [_jsx(DynamicIcon, { name: "sparkles", size: 16, color: "currentColor", strokeWidth: 2, className: "animate-pulse" }), isSubmitting ? "Signing up..." : "Sign Up"] }) }), _jsxs("div", { className: "text-center text-sm", children: [_jsx("span", { className: "text-muted-foreground", children: "Already have an account?" }), " ", _jsx(Button, { variant: "link", className: "p-0 h-auto", onClick: (e) => { e.preventDefault(); embedded && onSwitchMode ? onSwitchMode() : navigate('/signin'); }, children: "Sign In" })] })] }), _jsxs("div", { className: "mt-4 text-center text-xs text-muted-foreground", children: ["By registering you agree to our", " ", _jsx("a", { href: "/terms", className: "underline underline-offset-4 hover:text-foreground", children: "Terms of Service" }), ",", " ", _jsx("a", { href: "/eula", className: "underline underline-offset-4 hover:text-foreground", children: "EULA" }), ",", " ", _jsx("a", { href: "/privacy", className: "underline underline-offset-4 hover:text-foreground", children: "Privacy Policy" })] })] }));
80
87
  if (embedded) {
81
88
  return formContent;
82
89
  }
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
4
  import { cn } from "../shadcn/lib/utils.js";
5
5
  import { ChevronDownIcon } from "../icons/index.js";
6
+ import { useControllableState } from "./use-controllable-state.js";
6
7
  const AccordionContext = React.createContext({
7
8
  value: [],
8
9
  toggle: () => { },
@@ -20,14 +21,11 @@ function useItem() {
20
21
  * `--accordion-panel-height` var or keyframes).
21
22
  */
22
23
  function Accordion({ className, value: controlled, defaultValue = [], onValueChange, openMultiple = true, ...props }) {
23
- const [value, setValue] = React.useState(defaultValue);
24
- const isControlled = controlled !== undefined;
25
- const open = isControlled ? controlled : value;
26
- const commit = (next) => {
27
- if (!isControlled)
28
- setValue(next);
29
- onValueChange?.(next);
30
- };
24
+ const [open, setOpen] = useControllableState({
25
+ value: controlled,
26
+ defaultValue,
27
+ onChange: onValueChange,
28
+ });
31
29
  const toggle = (item) => {
32
30
  const has = open.includes(item);
33
31
  const next = has
@@ -35,9 +33,33 @@ function Accordion({ className, value: controlled, defaultValue = [], onValueCha
35
33
  : openMultiple
36
34
  ? [...open, item]
37
35
  : [item];
38
- commit(next);
36
+ setOpen(next);
37
+ };
38
+ const onKeyDown = (e) => {
39
+ props.onKeyDown?.(e);
40
+ if (e.defaultPrevented)
41
+ return;
42
+ if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key))
43
+ return;
44
+ const triggers = Array.from(e.currentTarget.querySelectorAll('[data-slot="accordion-trigger"]:not([disabled])'));
45
+ const current = triggers.findIndex((t) => t === document.activeElement);
46
+ if (current < 0)
47
+ return;
48
+ let next = -1;
49
+ if (e.key === "ArrowDown")
50
+ next = (current + 1) % triggers.length;
51
+ else if (e.key === "ArrowUp")
52
+ next = (current - 1 + triggers.length) % triggers.length;
53
+ else if (e.key === "Home")
54
+ next = 0;
55
+ else if (e.key === "End")
56
+ next = triggers.length - 1;
57
+ if (next >= 0) {
58
+ e.preventDefault();
59
+ triggers[next].focus();
60
+ }
39
61
  };
40
- return (_jsx(AccordionContext.Provider, { value: { value: open, toggle }, children: _jsx("div", { "data-slot": "accordion", className: cn("flex w-full flex-col", className), ...props }) }));
62
+ return (_jsx(AccordionContext.Provider, { value: { value: open, toggle }, children: _jsx("div", { "data-slot": "accordion", className: cn("flex w-full flex-col", className), ...props, onKeyDown: onKeyDown }) }));
41
63
  }
42
64
  function AccordionItem({ className, value, ...props }) {
43
65
  const { value: openValues } = React.useContext(AccordionContext);
@@ -23,9 +23,12 @@ function AlertDialog({ open, defaultOpen = false, onOpenChange, children, }) {
23
23
  onChange: onOpenChange,
24
24
  });
25
25
  const { titleId, descriptionId, registerTitle, registerDescription, labelledBy, describedBy, } = useDialogLabelling();
26
+ // Stable id wiring the trigger's aria-controls to the content's id.
27
+ const contentId = React.useId();
26
28
  return (_jsx(AlertDialogContext.Provider, { value: {
27
29
  open: isOpen,
28
30
  setOpen,
31
+ contentId,
29
32
  titleId,
30
33
  descriptionId,
31
34
  registerTitle,
@@ -35,10 +38,10 @@ function AlertDialog({ open, defaultOpen = false, onOpenChange, children, }) {
35
38
  }, children: children }));
36
39
  }
37
40
  function AlertDialogTrigger({ className, asChild = false, render, nativeButton: _nativeButton, children, onClick, ...props }) {
38
- const { open, setOpen } = useAlertDialog();
41
+ const { open, setOpen, contentId } = useAlertDialog();
39
42
  const { useSlot, slotChild } = resolveRender(asChild, render, children);
40
43
  const Comp = useSlot ? Slot : "button";
41
- return (_jsx(Comp, { type: useSlot ? undefined : "button", "data-slot": "alert-dialog-trigger", "aria-haspopup": "dialog", "data-state": open ? "open" : "closed", className: cn("cursor-pointer", className), onClick: (e) => {
44
+ return (_jsx(Comp, { type: useSlot ? undefined : "button", "data-slot": "alert-dialog-trigger", "aria-haspopup": "dialog", "aria-expanded": open, "aria-controls": open ? contentId : undefined, "data-state": open ? "open" : "closed", className: cn("cursor-pointer", className), onClick: (e) => {
42
45
  onClick?.(e);
43
46
  if (!e.defaultPrevented)
44
47
  setOpen(true);
@@ -51,7 +54,7 @@ function AlertDialogOverlay() {
51
54
  return null;
52
55
  }
53
56
  function AlertDialogContent({ className, size = "default", children, ...props }) {
54
- const { open, setOpen, labelledBy, describedBy } = useAlertDialog();
57
+ const { open, setOpen, contentId, labelledBy, describedBy } = useAlertDialog();
55
58
  const ref = React.useRef(null);
56
59
  React.useEffect(() => {
57
60
  const node = ref.current;
@@ -68,7 +71,7 @@ function AlertDialogContent({ className, size = "default", children, ...props })
68
71
  return (_jsx("dialog", { ref: ref, role: "alertdialog", "aria-labelledby": labelledBy, "aria-describedby": describedBy, "data-slot": "alert-dialog-overlay", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", onCancel: (e) => {
69
72
  e.preventDefault();
70
73
  setOpen(false);
71
- }, onClose: () => setOpen(false), className: "m-0 grid max-h-none max-w-none place-items-center bg-transparent p-4 backdrop:bg-black/10 backdrop:duration-100 supports-backdrop-filter:backdrop:backdrop-blur-xs open:fixed open:inset-0 open:size-full", children: open ? (_jsx("div", { "data-slot": "alert-dialog-content", "data-size": size, "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, className: cn("group/alert-dialog-content relative grid w-full gap-6 rounded-xl bg-background p-6 text-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95", className), ...props, children: children })) : null }));
74
+ }, onClose: () => setOpen(false), className: "m-0 grid max-h-none max-w-none place-items-center bg-transparent p-4 backdrop:bg-black/10 backdrop:duration-100 supports-backdrop-filter:backdrop:backdrop-blur-xs open:fixed open:inset-0 open:size-full", children: open ? (_jsx("div", { id: contentId, "data-slot": "alert-dialog-content", "data-size": size, "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, className: cn("group/alert-dialog-content relative grid w-full gap-6 rounded-xl bg-background p-6 text-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95", className), ...props, children: children })) : null }));
72
75
  }
73
76
  function AlertDialogHeader({ className, ...props }) {
74
77
  return (_jsx("div", { "data-slot": "alert-dialog-header", className: cn("grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]", className), ...props }));
package/dist/ui/avatar.js CHANGED
@@ -13,17 +13,30 @@ function Avatar({ className, size = "default", ...props }) {
13
13
  }
14
14
  function AvatarImage({ className, onLoad, onError, src, ...props }) {
15
15
  const { status, setStatus } = React.useContext(AvatarContext);
16
+ const imgRef = React.useRef(null);
16
17
  // A missing/empty src never fires load or error, so mark it errored up front;
17
18
  // otherwise the fallback would sit next to a blank img, each squeezed to half.
18
19
  React.useEffect(() => {
19
20
  if (!src)
20
21
  setStatus("error");
21
22
  }, [src, setStatus]);
23
+ // A cached image can finish loading before React wires up onLoad, leaving the
24
+ // fallback stuck over a hidden loaded image. Reconcile against the DOM on mount
25
+ // and on src change (matches Base UI's cached-image handling).
26
+ React.useLayoutEffect(() => {
27
+ const img = imgRef.current;
28
+ if (!img || !img.complete)
29
+ return;
30
+ if (img.naturalWidth > 0)
31
+ setStatus("loaded");
32
+ else
33
+ setStatus("error");
34
+ }, [src, setStatus]);
22
35
  // Render nothing until the image has actually loaded — the fallback owns the
23
36
  // box until then (matches Base UI, avoids the blank-img squeeze on slow loads).
24
37
  if (!src || status === "error")
25
38
  return null;
26
- return (_jsx("img", { src: src, "data-slot": "avatar-image", className: cn("absolute inset-0 aspect-square size-full rounded-full object-cover", status === "loaded" ? undefined : "opacity-0", className), onLoad: (e) => {
39
+ return (_jsx("img", { ref: imgRef, src: src, "data-slot": "avatar-image", className: cn("absolute inset-0 aspect-square size-full rounded-full object-cover", status === "loaded" ? undefined : "opacity-0", className), onLoad: (e) => {
27
40
  setStatus("loaded");
28
41
  onLoad?.(e);
29
42
  }, onError: (e) => {