@vvedantb/ui 0.1.5 → 0.2.1
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/README.md +49 -0
- package/app/README.md +100 -0
- package/app/src/auth/useStableAuth.ts +69 -0
- package/app/src/cn.ts +6 -0
- package/app/src/confirm/ConfirmSkipHint.tsx +35 -0
- package/app/src/confirm/confirmSkip.ts +26 -0
- package/app/src/confirm/useAltHeld.ts +128 -0
- package/app/src/confirm/useRequestConfirm.ts +25 -0
- package/app/src/index.ts +43 -0
- package/app/src/settings/SettingsEmptyState.tsx +38 -0
- package/app/src/settings/SettingsField.tsx +42 -0
- package/app/src/settings/SettingsPage.tsx +88 -0
- package/app/src/settings/SettingsSection.tsx +92 -0
- package/app/src/settings/SettingsStack.tsx +17 -0
- package/app/src/settings/SettingsToggleRow.tsx +51 -0
- package/app/src/update/deployFingerprint.ts +27 -0
- package/app/src/update/updateWatcher.ts +100 -0
- package/package.json +71 -11
- package/shell/src/PillTabs.tsx +86 -0
- package/shell/src/SidebarHeader.tsx +21 -0
- package/shell/src/cn.ts +5 -0
- package/shell/src/index.ts +6 -0
- package/src/ai-elements/artifact.tsx +1 -1
- package/src/ai-elements/attachments.tsx +3 -3
- package/src/ai-elements/chain-of-thought.tsx +5 -5
- package/src/ai-elements/code-block.tsx +2 -2
- package/src/ai-elements/conversation.tsx +3 -3
- package/src/ai-elements/inline-citation.tsx +6 -6
- package/src/ai-elements/message.tsx +2 -2
- package/src/ai-elements/prompt-input.tsx +3 -3
- package/src/ai-elements/queue.tsx +2 -2
- package/src/ai-elements/reasoning.tsx +1 -1
- package/src/ai-elements/shimmer.tsx +5 -5
- package/src/ai-elements/sources.tsx +2 -2
- package/src/ai-elements/speech-input.tsx +1 -1
- package/src/ai-elements/suggestion.tsx +2 -2
- package/src/index.ts +18 -18
- package/src/ui/{_menu-classes.vibot.ts → _menu-classes.alt.ts} +4 -4
- package/src/ui/_menu-classes.ts +4 -4
- package/src/ui/{accordion.verve.tsx → accordion.alt.tsx} +2 -2
- package/src/ui/badge.tsx +1 -1
- package/src/ui/breadcrumb.tsx +2 -2
- package/src/ui/button.tsx +3 -3
- package/src/ui/checkbox.tsx +1 -1
- package/src/ui/{clear-input.vibot.tsx → clear-input.alt.tsx} +1 -1
- package/src/ui/clear-input.tsx +1 -1
- package/src/ui/command.tsx +5 -5
- package/src/ui/dialog.tsx +1 -1
- package/src/ui/{input-group.verve.tsx → input-group.alt.tsx} +1 -1
- package/src/ui/input-group.tsx +2 -2
- package/src/ui/kbd.tsx +1 -1
- package/src/ui/labeled-switch-row.tsx +2 -2
- package/src/ui/progress.tsx +1 -1
- package/src/ui/radix-select.tsx +3 -3
- package/src/ui/search-input.tsx +1 -1
- package/src/ui/select.tsx +4 -4
- package/src/ui/sonner.tsx +5 -5
- package/src/ui/spinner.tsx +1 -1
- package/src/ui/switch.tsx +2 -2
- package/src/ui/table.tsx +2 -2
- package/src/ui/time-picker.tsx +3 -3
- package/tokens/README.md +98 -0
- package/tokens/examples/compat-shadcn-rgb/Fixture.tsx +45 -0
- package/tokens/examples/compat-shadcn-rgb/README.md +11 -0
- package/tokens/examples/compat-shadcn-rgb/index.html +12 -0
- package/tokens/examples/compat-shadcn-rgb/main.tsx +5 -0
- package/tokens/examples/compat-shadcn-rgb/package.json +14 -0
- package/tokens/examples/compat-shadcn-rgb/styles.css +67 -0
- package/tokens/examples/compat-shadcn-rgb/tsconfig.json +14 -0
- package/tokens/examples/compat-shadcn-rgb/vite.config.ts +11 -0
- package/tokens/examples/migration.md +303 -0
- package/tokens/src/css/animations.css +55 -0
- package/tokens/src/css/base.css +51 -0
- package/tokens/src/css/compat-shadcn-rgb.css +75 -0
- package/tokens/src/css/dialog.css +13 -0
- package/tokens/src/css/index.css +19 -0
- package/tokens/src/css/modal.css +44 -0
- package/tokens/src/css/scrollbar-thin.css +40 -0
- package/tokens/src/css/tabs-pills.css +58 -0
- package/tokens/src/css/tabs.css +57 -0
- package/tokens/src/css/utilities.css +34 -0
- package/tokens/src/native.ts +39 -0
- package/tokens/src/tailwind-preset.d.mts +25 -0
- package/tokens/src/tailwind-preset.mjs +183 -0
- package/tokens/src/tokens.css +207 -0
- /package/src/motion/{presets.vibot.ts → presets.alt.ts} +0 -0
- /package/src/ui/{scroll-area.verve.tsx → scroll-area.alt.tsx} +0 -0
package/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# @vvedantb/ui
|
|
2
|
+
|
|
3
|
+
Shared React 19 web UI. Source-exported: no build step.
|
|
4
|
+
|
|
5
|
+
| Import | What it is |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| `@vvedantb/ui` | Primitives (Radix-based) and AI elements. |
|
|
8
|
+
| `@vvedantb/ui/cn` | `cn` class helper (clsx + tailwind-merge). |
|
|
9
|
+
| `@vvedantb/ui/shell` | Router-free `SidebarHeader` and `PillTabs`. |
|
|
10
|
+
| `@vvedantb/ui/app` | Alt-click confirm skip, update watcher, settings chrome, `useStableAuth`. See `app/README.md` in the repository. |
|
|
11
|
+
| `@vvedantb/ui/tokens` | `tokens.css`: colour, radius, shadow, ease and font tokens for Tailwind v4. |
|
|
12
|
+
| `@vvedantb/ui/tokens/css/*.css` | `index`, `base`, `dialog`, `modal`, `tabs`, `tabs-pills`, `utilities`, `scrollbar-thin`, `animations`, `compat-shadcn-rgb`. |
|
|
13
|
+
| `@vvedantb/ui/tokens/compat/shadcn-rgb.css` | Same file as `tokens/css/compat-shadcn-rgb.css`. |
|
|
14
|
+
| `@vvedantb/ui/tokens/tailwind-preset` | Tailwind preset for apps that load a JS config with `@config`. |
|
|
15
|
+
| `@vvedantb/ui/tokens/native` | JS colours, radii and spacing. No CSS, no React DOM. |
|
|
16
|
+
|
|
17
|
+
```css
|
|
18
|
+
@import "tailwindcss";
|
|
19
|
+
@import "@vvedantb/ui/tokens";
|
|
20
|
+
@source "../node_modules/@vvedantb/ui/src/**/*.{ts,tsx}";
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Colour names in components
|
|
24
|
+
|
|
25
|
+
Components use `text-muted-foreground`, `primary` and `--color-*` theme variables. They no longer use `text-muted`, `*-accent` or raw `var(--muted)` reads, which clash with shadcn names. `tokens.css` still defines `--color-muted` and `--color-accent`. `pnpm --filter @vvedantb/ui check:tokens` fails if a component brings the old names back (it also runs in `build`, `test` and `verify`).
|
|
26
|
+
|
|
27
|
+
## Using an existing shadcn RGB theme
|
|
28
|
+
|
|
29
|
+
Use this when the app defines shadcn-style variables as space-separated channels (`--background: 244 245 246`) and reads them as `rgb(var(--background) / 0.5)`.
|
|
30
|
+
|
|
31
|
+
1. Import `compat-shadcn-rgb.css` **instead of** `tokens.css`. Never import both.
|
|
32
|
+
2. The app keeps `--background`, `--foreground`, `--card`, `--popover`, `--primary`, `--secondary`, `--muted`, `--accent`, `--destructive`, `--border`, `--input`, `--ring` (each with its `-foreground` where shadcn has one) and `--radius`. The compat file does not define them. It only references them, so the app can change `--primary`, `--ring`, `--radius` or `--font-sans` at runtime.
|
|
33
|
+
3. Add the `@source` line for the package sources.
|
|
34
|
+
|
|
35
|
+
```css
|
|
36
|
+
@import "tailwindcss";
|
|
37
|
+
@import "@vvedantb/ui/tokens/css/compat-shadcn-rgb.css";
|
|
38
|
+
@source "../node_modules/@vvedantb/ui/src/**/*.{ts,tsx}";
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Shared CSS that is safe without `tokens.css`: `compat-shadcn-rgb.css`, `dialog.css`, `utilities.css`, `scrollbar-thin.css` (reads `--scrollbar`, which the compat file defines), `modal.css` and `tabs.css` (both need the app motion variables listed in `tokens/README.md`), and `animations.css` (not with the Tailwind preset). `base.css` and `tabs-pills.css` read raw `--accent`, `--foreground`, `--muted`, `--segment` and `--default`; skip them unless the app defines those as full colour values.
|
|
42
|
+
|
|
43
|
+
The compat file includes a fallback `glass-panel-strong` (`rgb(var(--card) / 0.95)` with a 12px blur) for toasts and dialogs. Declare your own `@utility glass-panel-strong` after the import to override it.
|
|
44
|
+
|
|
45
|
+
A fixture that renders the main components against sample light and dark RGB variables is in `tokens/examples/compat-shadcn-rgb/`.
|
|
46
|
+
|
|
47
|
+
`@clerk/clerk-react`, `@tanstack/react-hotkeys` and `zod` are optional peers. Install them only if you import `@vvedantb/ui/app`.
|
|
48
|
+
|
|
49
|
+
Full documentation: the repository `README.md`. Upgrading from `@vvedantb/tokens`, `@vvedantb/shell` or `@vvedantb/app-kit`: `MIGRATION.md`.
|
package/app/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# `@vvedantb/ui/app`
|
|
2
|
+
|
|
3
|
+
Shared web React helpers that were duplicated across apps. Source-exported as the `./app` subpath of `@vvedantb/ui`.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @vvedantb/ui
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Peers
|
|
12
|
+
|
|
13
|
+
Required: `react` and `react-dom` (>=19). Optional peers of `@vvedantb/ui`, needed only for this subpath: `@clerk/clerk-react` (`useStableAuth`), `@tanstack/react-hotkeys` and `zod`.
|
|
14
|
+
|
|
15
|
+
`sonner` is not a dependency. The update watcher takes the toast API as an option. This subpath uses a local class join (`cn`), not the `cn` from `@vvedantb/ui`. Tailwind must scan `node_modules/@vvedantb/ui/app/src/**/*.{ts,tsx}` so the class names are generated, and the app needs the `@vvedantb/ui/tokens` names (`bg-surface-card`, `divide-separator`, `text-muted-foreground` and so on).
|
|
16
|
+
|
|
17
|
+
```css
|
|
18
|
+
@source "../node_modules/@vvedantb/ui/app/src/**/*.{ts,tsx}";
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Confirm skip (Alt-click)
|
|
22
|
+
|
|
23
|
+
```tsx
|
|
24
|
+
import { ConfirmSkipHint, skipConfirmTitle, useRequestConfirm } from "@vvedantb/ui/app";
|
|
25
|
+
|
|
26
|
+
const { request } = useRequestConfirm();
|
|
27
|
+
|
|
28
|
+
<DropdownMenuItem
|
|
29
|
+
title={skipConfirmTitle("Delete")}
|
|
30
|
+
onSelect={(event) => request(() => setOpen(true), () => remove(), event)}
|
|
31
|
+
>
|
|
32
|
+
Delete <ConfirmSkipHint />
|
|
33
|
+
</DropdownMenuItem>
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- `useRequestConfirm` does not subscribe to Alt. It reads `getAltHeld()` when the request happens, so the menu owner does not re-render when Alt goes down. It returns `{ request }` only.
|
|
37
|
+
- `ConfirmSkipHint` stays mounted and gains a `hidden` class when Alt is not held, so an open Radix menu keeps a stable child tree.
|
|
38
|
+
- The Alt store resets when the last subscriber leaves, clears on blur and tab hide, and cancels a bare Alt keydown only inside `[role='menu']` or `[data-radix-menu-content]`.
|
|
39
|
+
- Also exported: `useAltHeld`, `getAltHeld`, `createAltHeldStore`, `bindToWindow`, `shouldSkipConfirm`, `requestConfirm`.
|
|
40
|
+
|
|
41
|
+
## Update available watcher
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { toast } from "sonner";
|
|
45
|
+
import { startUpdateAvailableWatcher } from "@vvedantb/ui/app";
|
|
46
|
+
|
|
47
|
+
useEffect(
|
|
48
|
+
() =>
|
|
49
|
+
startUpdateAvailableWatcher({
|
|
50
|
+
origin: window.location.origin,
|
|
51
|
+
toast,
|
|
52
|
+
closeConvex: () => void convex.close(),
|
|
53
|
+
enabled: !import.meta.env.DEV,
|
|
54
|
+
}),
|
|
55
|
+
[],
|
|
56
|
+
);
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Options: `origin`, `toast`, `closeConvex?`, `pollMs?` (default 60000), `toastId?` (default `app-update-available`), `enabled?` (default true), `reload?` (default `window.location.reload`). On a changed asset fingerprint it shows a sticky "Update available" toast. Refresh calls `closeConvex` (errors ignored), then reloads. `fingerprintFromHtml` and `fetchDeployFingerprint` are exported too. The fingerprint looks for Vite `/assets/*.js` URLs in the index HTML.
|
|
60
|
+
|
|
61
|
+
## Settings chrome
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
import { SettingsPage, SettingsSection, SettingsToggleRow } from "@vvedantb/ui/app";
|
|
65
|
+
|
|
66
|
+
<SettingsPage
|
|
67
|
+
title="Notifications"
|
|
68
|
+
layout={({ title, headerRight, toolbar, tabs, fillHeight, children }) => (
|
|
69
|
+
<PageContainer title={title} rightSection={headerRight} toolbar={toolbar} tabs={tabs} noScroll={fillHeight}>
|
|
70
|
+
{children}
|
|
71
|
+
</PageContainer>
|
|
72
|
+
)}
|
|
73
|
+
>
|
|
74
|
+
<SettingsSection title="Email" bodyVariant="list">
|
|
75
|
+
<SettingsToggleRow title="Weekly digest" action={<Switch />} />
|
|
76
|
+
</SettingsSection>
|
|
77
|
+
</SettingsPage>
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Exports: `SettingsSection`, `SettingsField`, `SettingsStack`, `SettingsToggleRow`, `SettingsEmptyState` (icon is `ComponentType<{ size?: number; className?: string }>`), `SettingsPage`. Without `layout`, `SettingsPage` renders a plain `div` with a heading and the optional slots.
|
|
81
|
+
|
|
82
|
+
## useStableAuth
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import { useStableAuth } from "@vvedantb/ui/app"; // 2000 ms debounce, wraps Clerk useAuth
|
|
86
|
+
import { createUseStableAuth } from "@vvedantb/ui/app";
|
|
87
|
+
|
|
88
|
+
const useAuth = createUseStableAuth(useClerkAuth, { debounceMs: 3000 });
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
After a signed-in session, a drop to signed-out reports `isLoaded: false` for the debounce window so Convex does not cascade. State is module-level. `resetStableAuthSession()` clears it and is for tests only.
|
|
92
|
+
|
|
93
|
+
## Develop
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
pnpm --filter @vvedantb/ui typecheck
|
|
97
|
+
pnpm --filter @vvedantb/ui test
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Source is in `app/src` and tests in `app/tests`. Publish steps are in the root `README.md`.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useAuth } from "@clerk/clerk-react";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
|
|
4
|
+
/** The two Clerk `useAuth` fields the hook reads. */
|
|
5
|
+
export type ClerkAuthSlice = {
|
|
6
|
+
isLoaded: boolean;
|
|
7
|
+
isSignedIn: boolean | undefined;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type StableAuthOptions = {
|
|
11
|
+
/** How long a sign-in loss reads as "still loading". Default 2000 ms. */
|
|
12
|
+
debounceMs?: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Tracks sign-in during this session to detect stale deploy auth loss.
|
|
16
|
+
let wasEverSignedIn = false;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Test-only. Clears the module-level "was ever signed in" flag so one test
|
|
20
|
+
* cannot leak state into the next. Do not call from app code.
|
|
21
|
+
*/
|
|
22
|
+
export function resetStableAuthSession(): void {
|
|
23
|
+
wasEverSignedIn = false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build a `useAuth` wrapper that debounces a brief signed-in to signed-out
|
|
28
|
+
* flicker (for example after a deploy) by reporting `isLoaded: false` for
|
|
29
|
+
* `debounceMs`, so Convex does not cascade on a transient auth loss.
|
|
30
|
+
*/
|
|
31
|
+
export function createUseStableAuth<T extends ClerkAuthSlice>(
|
|
32
|
+
useAuthHook: () => T,
|
|
33
|
+
options: StableAuthOptions = {},
|
|
34
|
+
): () => T {
|
|
35
|
+
const debounceMs = options.debounceMs ?? 2000;
|
|
36
|
+
|
|
37
|
+
return function useStableAuth(): T {
|
|
38
|
+
const auth = useAuthHook();
|
|
39
|
+
const [overrideLoading, setOverrideLoading] = useState(false);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (auth.isSignedIn) {
|
|
43
|
+
wasEverSignedIn = true;
|
|
44
|
+
setOverrideLoading(false);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (wasEverSignedIn && auth.isLoaded && !auth.isSignedIn) {
|
|
49
|
+
setOverrideLoading(true);
|
|
50
|
+
const timer = setTimeout(() => {
|
|
51
|
+
wasEverSignedIn = false;
|
|
52
|
+
setOverrideLoading(false);
|
|
53
|
+
}, debounceMs);
|
|
54
|
+
return () => clearTimeout(timer);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
setOverrideLoading(false);
|
|
58
|
+
}, [auth.isLoaded, auth.isSignedIn]);
|
|
59
|
+
|
|
60
|
+
if (overrideLoading) {
|
|
61
|
+
return { ...auth, isLoaded: false };
|
|
62
|
+
}
|
|
63
|
+
return auth;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** `useAuth` from `@clerk/clerk-react` with the 2000 ms debounce. */
|
|
68
|
+
export const useStableAuth: () => ReturnType<typeof useAuth> =
|
|
69
|
+
createUseStableAuth(useAuth);
|
package/app/src/cn.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { formatForDisplay, type Hotkey } from "@tanstack/react-hotkeys";
|
|
2
|
+
import { cn } from "../cn.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { useAltHeld } from "./useAltHeld.js";
|
|
5
|
+
|
|
6
|
+
// `Hotkey` covers combinations, not a bare modifier, so the literal is parsed
|
|
7
|
+
// in rather than asserted. `formatForDisplay` renders it as ⌥ / Alt.
|
|
8
|
+
const ALT_HOTKEY = z.custom<Hotkey>().parse("Alt");
|
|
9
|
+
|
|
10
|
+
/** Platform-native "⌥-click skips confirmation" / "Alt-click skips confirmation". */
|
|
11
|
+
export function skipConfirmTitle(actionTitle?: string): string {
|
|
12
|
+
const skip = `${formatForDisplay(ALT_HOTKEY)}-click skips confirmation`;
|
|
13
|
+
return actionTitle ? `${actionTitle} · ${skip}` : skip;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Visible only while Alt is held. Stays mounted (hidden) otherwise so an open
|
|
18
|
+
* Radix menu item keeps a stable child tree. Sits after the action label so every
|
|
19
|
+
* confirmable control shows that this click will skip the dialog.
|
|
20
|
+
*/
|
|
21
|
+
export function ConfirmSkipHint({ className }: { className?: string }) {
|
|
22
|
+
const altHeld = useAltHeld();
|
|
23
|
+
return (
|
|
24
|
+
<kbd
|
|
25
|
+
className={cn(
|
|
26
|
+
"ml-auto shrink-0 rounded bg-surface-secondary px-1 py-0.5 font-mono text-[10px] text-muted-foreground",
|
|
27
|
+
!altHeld && "hidden",
|
|
28
|
+
className,
|
|
29
|
+
)}
|
|
30
|
+
aria-hidden
|
|
31
|
+
>
|
|
32
|
+
{formatForDisplay(ALT_HOTKEY)}
|
|
33
|
+
</kbd>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether this activation should skip the confirmation dialog.
|
|
3
|
+
*
|
|
4
|
+
* `event.altKey` covers the click itself. `altHeld` covers Radix `onSelect`
|
|
5
|
+
* (and keyboard activation) which do not carry the modifier.
|
|
6
|
+
*/
|
|
7
|
+
export function shouldSkipConfirm(
|
|
8
|
+
altHeld: boolean,
|
|
9
|
+
event?: { altKey?: boolean } | null,
|
|
10
|
+
): boolean {
|
|
11
|
+
return altHeld || event?.altKey === true;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Open the dialog, or run the action immediately when Alt is held. */
|
|
15
|
+
export function requestConfirm(
|
|
16
|
+
altHeld: boolean,
|
|
17
|
+
open: () => void,
|
|
18
|
+
confirm: () => void,
|
|
19
|
+
event?: { altKey?: boolean } | null,
|
|
20
|
+
): void {
|
|
21
|
+
if (shouldSkipConfirm(altHeld, event)) {
|
|
22
|
+
confirm();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
open();
|
|
26
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether Alt (Option on macOS) is held right now.
|
|
5
|
+
*
|
|
6
|
+
* Subscribes to the window so every confirmable control can restyle when the
|
|
7
|
+
* modifier goes down, and so Alt+click still works when the activating event
|
|
8
|
+
* is a Radix `onSelect` that does not carry `altKey`.
|
|
9
|
+
*
|
|
10
|
+
* Cleared on blur / visibility hide so a missed keyup after alt-tab cannot
|
|
11
|
+
* leave the app stuck in skip-confirm mode.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The only two fields the store reads off a keyboard event. */
|
|
15
|
+
export type AltKeyEvent = { key: string; altKey: boolean };
|
|
16
|
+
|
|
17
|
+
/** What the binding feeds the store. `clear` is any event that can swallow a keyup. */
|
|
18
|
+
export type AltHeldSignals = {
|
|
19
|
+
keyDown: (event: AltKeyEvent) => void;
|
|
20
|
+
keyUp: (event: AltKeyEvent) => void;
|
|
21
|
+
clear: () => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Wires the signals to a real event source and returns the unbind. */
|
|
25
|
+
export type AltHeldBinding = (signals: AltHeldSignals) => () => void;
|
|
26
|
+
|
|
27
|
+
export type AltHeldStore = {
|
|
28
|
+
subscribe: (onStoreChange: () => void) => () => void;
|
|
29
|
+
getSnapshot: () => boolean;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The binding is injected so the state machine can be driven without a DOM.
|
|
34
|
+
* Listeners attach lazily and, crucially, the held flag resets when the last
|
|
35
|
+
* subscriber leaves: with nothing bound, a keyup can no longer be observed, so
|
|
36
|
+
* a flag left at `true` would survive into the next mount and silently skip the
|
|
37
|
+
* confirmation dialog on a plain click.
|
|
38
|
+
*/
|
|
39
|
+
export function createAltHeldStore(bind: AltHeldBinding): AltHeldStore {
|
|
40
|
+
let altHeld = false;
|
|
41
|
+
let unbind: (() => void) | null = null;
|
|
42
|
+
const listeners = new Set<() => void>();
|
|
43
|
+
|
|
44
|
+
function setHeld(next: boolean): void {
|
|
45
|
+
if (altHeld === next) return;
|
|
46
|
+
altHeld = next;
|
|
47
|
+
for (const listener of listeners) listener();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const signals: AltHeldSignals = {
|
|
51
|
+
keyDown: (event) => {
|
|
52
|
+
if (event.key === "Alt" || event.altKey) setHeld(true);
|
|
53
|
+
},
|
|
54
|
+
keyUp: (event) => {
|
|
55
|
+
// A keyup for some other key still reports altKey while Alt is down, so
|
|
56
|
+
// only a released Alt — or a keyup without the modifier — clears.
|
|
57
|
+
if (event.key === "Alt" || !event.altKey) setHeld(false);
|
|
58
|
+
},
|
|
59
|
+
clear: () => setHeld(false),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
subscribe: (onStoreChange) => {
|
|
64
|
+
if (unbind === null) unbind = bind(signals);
|
|
65
|
+
listeners.add(onStoreChange);
|
|
66
|
+
return () => {
|
|
67
|
+
listeners.delete(onStoreChange);
|
|
68
|
+
if (listeners.size > 0) return;
|
|
69
|
+
unbind?.();
|
|
70
|
+
unbind = null;
|
|
71
|
+
// Nothing is listening for the keyup any more; start the next mount
|
|
72
|
+
// from a known-unheld state rather than a stale `true`.
|
|
73
|
+
altHeld = false;
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
getSnapshot: () => altHeld,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isInOpenMenu(target: EventTarget | null): boolean {
|
|
81
|
+
return (
|
|
82
|
+
target instanceof Element &&
|
|
83
|
+
target.closest("[role='menu'], [data-radix-menu-content]") !== null
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Default binding: window key events plus blur and tab-hide clears. */
|
|
88
|
+
export const bindToWindow: AltHeldBinding = (signals) => {
|
|
89
|
+
if (typeof window === "undefined") return () => {};
|
|
90
|
+
const onKeyDown = (event: KeyboardEvent): void => {
|
|
91
|
+
// A bare Alt press can steal focus to the browser menu bar and dismiss an
|
|
92
|
+
// open Radix menu. Cancel it only when it lands inside one.
|
|
93
|
+
if (event.key === "Alt" && isInOpenMenu(event.target)) {
|
|
94
|
+
event.preventDefault();
|
|
95
|
+
}
|
|
96
|
+
signals.keyDown(event);
|
|
97
|
+
};
|
|
98
|
+
const onKeyUp = (event: KeyboardEvent): void => signals.keyUp(event);
|
|
99
|
+
const onBlur = (): void => signals.clear();
|
|
100
|
+
const onVisibilityChange = (): void => {
|
|
101
|
+
if (document.hidden) signals.clear();
|
|
102
|
+
};
|
|
103
|
+
window.addEventListener("keydown", onKeyDown);
|
|
104
|
+
window.addEventListener("keyup", onKeyUp);
|
|
105
|
+
window.addEventListener("blur", onBlur);
|
|
106
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
107
|
+
return () => {
|
|
108
|
+
window.removeEventListener("keydown", onKeyDown);
|
|
109
|
+
window.removeEventListener("keyup", onKeyUp);
|
|
110
|
+
window.removeEventListener("blur", onBlur);
|
|
111
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const altHeldStore = createAltHeldStore(bindToWindow);
|
|
116
|
+
|
|
117
|
+
/** Current value without subscribing. Safe to read inside event handlers. */
|
|
118
|
+
export function getAltHeld(): boolean {
|
|
119
|
+
return altHeldStore.getSnapshot();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function useAltHeld(): boolean {
|
|
123
|
+
return useSyncExternalStore(
|
|
124
|
+
altHeldStore.subscribe,
|
|
125
|
+
altHeldStore.getSnapshot,
|
|
126
|
+
() => false,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { requestConfirm } from "./confirmSkip.js";
|
|
2
|
+
import { getAltHeld } from "./useAltHeld.js";
|
|
3
|
+
|
|
4
|
+
type ConfirmEvent = { altKey?: boolean } | null;
|
|
5
|
+
|
|
6
|
+
function request(
|
|
7
|
+
open: () => void,
|
|
8
|
+
confirm: () => void,
|
|
9
|
+
event?: ConfirmEvent,
|
|
10
|
+
): void {
|
|
11
|
+
requestConfirm(getAltHeld(), open, confirm, event);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const api = { request } as const;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Confirm helper for menu owners. It must not subscribe to Alt: a re-render of
|
|
18
|
+
* the owner when Alt goes down remounts the open Radix menu and closes it.
|
|
19
|
+
* `ConfirmSkipHint` is the only subscriber and lives inside the open menu.
|
|
20
|
+
* The Alt state is read when the request happens. The returned object is
|
|
21
|
+
* stable and holds no React state, so it is safe to call anywhere.
|
|
22
|
+
*/
|
|
23
|
+
export function useRequestConfirm() {
|
|
24
|
+
return api;
|
|
25
|
+
}
|
package/app/src/index.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export {
|
|
2
|
+
bindToWindow,
|
|
3
|
+
createAltHeldStore,
|
|
4
|
+
getAltHeld,
|
|
5
|
+
useAltHeld,
|
|
6
|
+
type AltHeldBinding,
|
|
7
|
+
type AltHeldSignals,
|
|
8
|
+
type AltHeldStore,
|
|
9
|
+
type AltKeyEvent,
|
|
10
|
+
} from "./confirm/useAltHeld.js";
|
|
11
|
+
export { requestConfirm, shouldSkipConfirm } from "./confirm/confirmSkip.js";
|
|
12
|
+
export { useRequestConfirm } from "./confirm/useRequestConfirm.js";
|
|
13
|
+
export { ConfirmSkipHint, skipConfirmTitle } from "./confirm/ConfirmSkipHint.js";
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
fetchDeployFingerprint,
|
|
17
|
+
fingerprintFromHtml,
|
|
18
|
+
} from "./update/deployFingerprint.js";
|
|
19
|
+
export {
|
|
20
|
+
startUpdateAvailableWatcher,
|
|
21
|
+
type UpdateAvailableWatcherOptions,
|
|
22
|
+
type UpdateToastOptions,
|
|
23
|
+
} from "./update/updateWatcher.js";
|
|
24
|
+
|
|
25
|
+
export { SettingsSection } from "./settings/SettingsSection.js";
|
|
26
|
+
export { SettingsField } from "./settings/SettingsField.js";
|
|
27
|
+
export { SettingsStack } from "./settings/SettingsStack.js";
|
|
28
|
+
export { SettingsToggleRow } from "./settings/SettingsToggleRow.js";
|
|
29
|
+
export { SettingsEmptyState } from "./settings/SettingsEmptyState.js";
|
|
30
|
+
export {
|
|
31
|
+
DefaultSettingsLayout,
|
|
32
|
+
SettingsPage,
|
|
33
|
+
type SettingsLayoutProps,
|
|
34
|
+
type SettingsPageProps,
|
|
35
|
+
} from "./settings/SettingsPage.js";
|
|
36
|
+
|
|
37
|
+
export {
|
|
38
|
+
createUseStableAuth,
|
|
39
|
+
resetStableAuthSession,
|
|
40
|
+
useStableAuth,
|
|
41
|
+
type ClerkAuthSlice,
|
|
42
|
+
type StableAuthOptions,
|
|
43
|
+
} from "./auth/useStableAuth.js";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
interface SettingsEmptyStateProps {
|
|
4
|
+
/** Tabler icon component, rendered muted above the copy. */
|
|
5
|
+
icon: ComponentType<{ size?: number; className?: string }>;
|
|
6
|
+
title: string;
|
|
7
|
+
/** One line on what to do about it. */
|
|
8
|
+
description?: string;
|
|
9
|
+
/** Primary call to action, e.g. the same button as the section header. */
|
|
10
|
+
action?: ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The "nothing here yet" state for a settings section body.
|
|
15
|
+
*
|
|
16
|
+
* Sits inside a SettingsSection rather than floating on the canvas, so an empty
|
|
17
|
+
* list still reads as part of the card it belongs to.
|
|
18
|
+
* Shared across apps.
|
|
19
|
+
*/
|
|
20
|
+
export function SettingsEmptyState({
|
|
21
|
+
icon: Icon,
|
|
22
|
+
title,
|
|
23
|
+
description,
|
|
24
|
+
action,
|
|
25
|
+
}: SettingsEmptyStateProps) {
|
|
26
|
+
return (
|
|
27
|
+
<div className="flex flex-col items-center justify-center px-4 py-16 text-center">
|
|
28
|
+
<Icon size={28} className="mb-5 text-muted-foreground opacity-50" />
|
|
29
|
+
<h4 className="text-sm font-medium text-foreground">{title}</h4>
|
|
30
|
+
{description ? (
|
|
31
|
+
<p className="mt-2 max-w-sm text-xs leading-relaxed text-muted-foreground">
|
|
32
|
+
{description}
|
|
33
|
+
</p>
|
|
34
|
+
) : null}
|
|
35
|
+
{action ? <div className="mt-6">{action}</div> : null}
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
interface SettingsFieldProps {
|
|
4
|
+
/** Field name, shown above the control. */
|
|
5
|
+
label: ReactNode;
|
|
6
|
+
/** Help text shown under the control. */
|
|
7
|
+
description?: ReactNode;
|
|
8
|
+
/** `id` of the control, so clicking the label focuses it. */
|
|
9
|
+
htmlFor?: string;
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One labelled control inside a SettingsSection body.
|
|
15
|
+
*
|
|
16
|
+
* Every settings form repeats the same label / control / help-text stack, so it
|
|
17
|
+
* lives here rather than being re-spaced per field. Stack several inside a
|
|
18
|
+
* section body with `grid gap-5`. Shared across apps.
|
|
19
|
+
*/
|
|
20
|
+
export function SettingsField({
|
|
21
|
+
label,
|
|
22
|
+
description,
|
|
23
|
+
htmlFor,
|
|
24
|
+
children,
|
|
25
|
+
}: SettingsFieldProps) {
|
|
26
|
+
return (
|
|
27
|
+
<div>
|
|
28
|
+
<label
|
|
29
|
+
htmlFor={htmlFor}
|
|
30
|
+
className="mb-2 block text-sm font-medium text-foreground"
|
|
31
|
+
>
|
|
32
|
+
{label}
|
|
33
|
+
</label>
|
|
34
|
+
{children}
|
|
35
|
+
{description ? (
|
|
36
|
+
<p className="mt-1.5 text-pretty text-xs leading-relaxed text-muted-foreground">
|
|
37
|
+
{description}
|
|
38
|
+
</p>
|
|
39
|
+
) : null}
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "../cn.js";
|
|
3
|
+
import { SettingsStack } from "./SettingsStack.js";
|
|
4
|
+
|
|
5
|
+
export type SettingsLayoutProps = {
|
|
6
|
+
title: string;
|
|
7
|
+
headerRight?: ReactNode;
|
|
8
|
+
toolbar?: ReactNode;
|
|
9
|
+
tabs?: ReactNode;
|
|
10
|
+
fillHeight?: boolean;
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export interface SettingsPageProps {
|
|
15
|
+
title: string;
|
|
16
|
+
/** Sparse primary action(s) in the title row (e.g. Add, Delete). */
|
|
17
|
+
headerRight?: ReactNode;
|
|
18
|
+
/** Filters / search / segmented controls. Sits under the title. */
|
|
19
|
+
toolbar?: ReactNode;
|
|
20
|
+
/** Route tabs. Sit under the title (and under toolbar if both exist). */
|
|
21
|
+
tabs?: ReactNode;
|
|
22
|
+
/**
|
|
23
|
+
* Wrap children in the standard settings section stack. Set false when the
|
|
24
|
+
* child already owns its own vertical rhythm.
|
|
25
|
+
*/
|
|
26
|
+
stack?: boolean;
|
|
27
|
+
/** Fill the remaining shell height instead of scrolling the page. */
|
|
28
|
+
fillHeight?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Page chrome supplied by the app (for example its own page container).
|
|
31
|
+
* Receives the stacked children. Defaults to a plain `div` layout.
|
|
32
|
+
*/
|
|
33
|
+
layout?: (props: SettingsLayoutProps) => ReactNode;
|
|
34
|
+
children: ReactNode;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Plain fallback layout: heading, optional slots, then the body. */
|
|
38
|
+
export function DefaultSettingsLayout({
|
|
39
|
+
title,
|
|
40
|
+
headerRight,
|
|
41
|
+
toolbar,
|
|
42
|
+
tabs,
|
|
43
|
+
fillHeight = false,
|
|
44
|
+
children,
|
|
45
|
+
}: SettingsLayoutProps) {
|
|
46
|
+
return (
|
|
47
|
+
<div
|
|
48
|
+
className={cn(
|
|
49
|
+
"mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 py-6",
|
|
50
|
+
fillHeight && "h-full min-h-0",
|
|
51
|
+
)}
|
|
52
|
+
>
|
|
53
|
+
<div className="flex items-center justify-between gap-4">
|
|
54
|
+
<h1 className="text-balance text-lg font-semibold text-foreground">
|
|
55
|
+
{title}
|
|
56
|
+
</h1>
|
|
57
|
+
{headerRight ? <div className="shrink-0">{headerRight}</div> : null}
|
|
58
|
+
</div>
|
|
59
|
+
{toolbar ? <div>{toolbar}</div> : null}
|
|
60
|
+
{tabs ? <div>{tabs}</div> : null}
|
|
61
|
+
<div className={cn(fillHeight ? "min-h-0 flex-1" : null)}>{children}</div>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Shared chrome for settings: title row, optional toolbar / tabs, then a
|
|
68
|
+
* consistent section stack. Apps pass `layout` to wrap their own page
|
|
69
|
+
* container; this package does not import one.
|
|
70
|
+
*/
|
|
71
|
+
export function SettingsPage({
|
|
72
|
+
title,
|
|
73
|
+
headerRight,
|
|
74
|
+
toolbar,
|
|
75
|
+
tabs,
|
|
76
|
+
stack = true,
|
|
77
|
+
fillHeight = false,
|
|
78
|
+
layout,
|
|
79
|
+
children,
|
|
80
|
+
}: SettingsPageProps) {
|
|
81
|
+
const body = stack ? <SettingsStack>{children}</SettingsStack> : children;
|
|
82
|
+
const render = layout ?? DefaultSettingsLayout;
|
|
83
|
+
return (
|
|
84
|
+
<>
|
|
85
|
+
{render({ title, headerRight, toolbar, tabs, fillHeight, children: body })}
|
|
86
|
+
</>
|
|
87
|
+
);
|
|
88
|
+
}
|