@horon-ai/react 0.1.1 → 0.1.2
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 +2 -2
- package/src/components/agent-activity.tsx +75 -7
- package/src/components/agent-lane.tsx +32 -3
- package/src/components/agent-lanes-timeline.tsx +85 -7
- package/src/components/agent-panel.tsx +46 -4
- package/src/components/agent-steering-panel.tsx +53 -3
- package/src/components/approval-card.tsx +34 -2
- package/src/components/avatar.tsx +20 -4
- package/src/components/badge.tsx +24 -3
- package/src/components/banner.tsx +19 -3
- package/src/components/bot-instructions-panel.tsx +86 -7
- package/src/components/bot-list-row.tsx +13 -1
- package/src/components/bot-memory-panel.tsx +58 -3
- package/src/components/bottom-sheet.tsx +61 -7
- package/src/components/breadcrumb-header.tsx +32 -2
- package/src/components/breadcrumb.tsx +31 -1
- package/src/components/button.tsx +46 -1
- package/src/components/calendar.tsx +76 -6
- package/src/components/card.tsx +30 -1
- package/src/components/chat-bubble.tsx +32 -3
- package/src/components/chat-controls.tsx +60 -7
- package/src/components/checkbox.tsx +22 -1
- package/src/components/chip.tsx +29 -2
- package/src/components/choice-card.tsx +25 -1
- package/src/components/code-panel.tsx +28 -3
- package/src/components/combobox.tsx +52 -2
- package/src/components/command-palette.tsx +65 -1
- package/src/components/composer.tsx +28 -1
- package/src/components/data-table.tsx +198 -16
- package/src/components/date-field.tsx +31 -2
- package/src/components/dialog.tsx +45 -3
- package/src/components/directory-card.tsx +23 -2
- package/src/components/document-extract-panel.tsx +83 -5
- package/src/components/empty-state.tsx +21 -2
- package/src/components/field.tsx +49 -3
- package/src/components/file-drop.tsx +39 -4
- package/src/components/filter-bar.tsx +38 -1
- package/src/components/flue-background.tsx +88 -4
- package/src/components/horon-icon.tsx +50 -3
- package/src/components/horon-wordmark.tsx +26 -1
- package/src/components/icon-button.tsx +38 -1
- package/src/components/input-group.tsx +57 -6
- package/src/components/input.tsx +48 -2
- package/src/components/integration-row.tsx +43 -2
- package/src/components/kbd.tsx +28 -2
- package/src/components/knowledge-base-panel.tsx +40 -2
- package/src/components/liquid-glass.tsx +16 -0
- package/src/components/list-detail.tsx +89 -7
- package/src/components/marketplace-card.tsx +40 -4
- package/src/components/mcp-server-row.tsx +49 -3
- package/src/components/menu.tsx +83 -3
- package/src/components/message-extras.tsx +50 -4
- package/src/components/modal.tsx +71 -4
- package/src/components/orchestrator-panel.tsx +48 -4
- package/src/components/page-header.tsx +26 -3
- package/src/components/plan-card.tsx +28 -1
- package/src/components/plan-step.tsx +41 -1
- package/src/components/plugin-row.tsx +61 -3
- package/src/components/progress.tsx +32 -2
- package/src/components/radio-group.tsx +55 -2
- package/src/components/research-run-panel.tsx +73 -3
- package/src/components/schedule.tsx +163 -12
- package/src/components/segmented-control.tsx +57 -2
- package/src/components/select.tsx +60 -0
- package/src/components/settings-list.tsx +66 -8
- package/src/components/sidebar.tsx +214 -23
- package/src/components/skeleton.tsx +61 -4
- package/src/components/slider.tsx +30 -0
- package/src/components/source-row.tsx +43 -4
- package/src/components/spinner.tsx +29 -2
- package/src/components/split-button.tsx +37 -3
- package/src/components/switch.tsx +31 -1
- package/src/components/tab-bar.tsx +47 -2
- package/src/components/tabs.tsx +59 -5
- package/src/components/task-row.tsx +49 -3
- package/src/components/textarea-field.tsx +36 -2
- package/src/components/textarea.tsx +35 -2
- package/src/components/toast.tsx +52 -3
- package/src/components/tool-call.tsx +87 -4
- package/src/components/tool-calls-header.tsx +32 -1
- package/src/components/tool-permissions.tsx +50 -2
- package/src/components/tooltip.tsx +33 -1
- package/src/components/top-bar.tsx +35 -2
- package/src/components/usage-meter.tsx +28 -2
- package/src/components/workspace-menu.tsx +46 -1
- package/src/hooks/use-active-indicator.ts +44 -1
- package/src/hooks/use-color-scheme.ts +24 -1
- package/src/hooks/use-copy-to-clipboard.ts +23 -0
- package/src/hooks/use-device.ts +29 -1
- package/src/hooks/use-hotkey.ts +21 -0
- package/src/hooks/use-media-query.ts +18 -0
- package/src/hooks/use-platform.ts +30 -1
- package/src/hooks/use-reduced-motion.ts +18 -1
- package/src/index.ts +31 -0
- package/src/lib/cn.ts +13 -1
- package/src/lib/cursor-ghost.ts +16 -6
- package/src/lib/platform.ts +71 -4
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useSyncExternalStore } from "react";
|
|
2
2
|
|
|
3
|
+
/** The two schemes the `.dark` class switch can report. */
|
|
3
4
|
export type ColorScheme = "light" | "dark";
|
|
4
5
|
|
|
5
6
|
const noop = () => {};
|
|
@@ -21,7 +22,29 @@ function readIsDark(): boolean {
|
|
|
21
22
|
return document.documentElement.classList.contains("dark");
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* The active color scheme; `light` on the server and before hydration.
|
|
27
|
+
*
|
|
28
|
+
* The `.dark` class on <html> is the only source, watched with a `MutationObserver`, so a toggle anywhere on the
|
|
29
|
+
* page re-renders every caller. `prefers-color-scheme` is deliberately ignored: the theme is an explicit switch,
|
|
30
|
+
* not an OS follow. A server render reports `light` until the class is read on the client.
|
|
31
|
+
*
|
|
32
|
+
* @returns `dark` while the root element carries the `.dark` class, `light` otherwise.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```tsx
|
|
36
|
+
* import { useColorScheme } from "@horon-ai/react";
|
|
37
|
+
*
|
|
38
|
+
* function ThemeToggle() {
|
|
39
|
+
* const scheme = useColorScheme();
|
|
40
|
+
* return (
|
|
41
|
+
* <Button variant="ghost" onClick={() => document.documentElement.classList.toggle("dark")}>
|
|
42
|
+
* {scheme === "dark" ? "Light" : "Dark"}
|
|
43
|
+
* </Button>
|
|
44
|
+
* );
|
|
45
|
+
* }
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
25
48
|
export function useColorScheme(): ColorScheme {
|
|
26
49
|
return useSyncExternalStore(subscribe, readIsDark, () => false) ? "dark" : "light";
|
|
27
50
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { useCallback, useEffect, useState } from "react";
|
|
2
2
|
|
|
3
|
+
/** What {@link useCopyToClipboard} returns: the transient flag and the write function. */
|
|
3
4
|
export type CopyToClipboard = {
|
|
5
|
+
/** True from a successful write until `resetMs` has passed; stays false when the write fails. */
|
|
4
6
|
copied: boolean;
|
|
5
7
|
/** Writes `text`; a failed write leaves `copied` false instead of throwing. */
|
|
6
8
|
copy: (text: string) => void;
|
|
@@ -10,6 +12,27 @@ export type CopyToClipboard = {
|
|
|
10
12
|
* Clipboard writes with a self-clearing confirmation: `copied` turns true when
|
|
11
13
|
* the write resolves and back to false after `resetMs`, so a button needs no
|
|
12
14
|
* timer of its own.
|
|
15
|
+
*
|
|
16
|
+
* The write goes through `navigator.clipboard`, so it needs a secure context and a user gesture; where that API
|
|
17
|
+
* is missing the call is a no-op and `copied` never turns true. The timer is cleared when the component unmounts
|
|
18
|
+
* or `resetMs` changes.
|
|
19
|
+
*
|
|
20
|
+
* @param resetMs - How long `copied` stays true after a successful write, in milliseconds. @default 1500
|
|
21
|
+
* @returns A {@link CopyToClipboard}: the current `copied` flag and a stable `copy` function.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```tsx
|
|
25
|
+
* import { useCopyToClipboard } from "@horon-ai/react";
|
|
26
|
+
*
|
|
27
|
+
* function CopyRow({ value }: { value: string }) {
|
|
28
|
+
* const { copied, copy } = useCopyToClipboard();
|
|
29
|
+
* return (
|
|
30
|
+
* <Button variant="ghost" onClick={() => copy(value)}>
|
|
31
|
+
* {copied ? "Copied" : "Copy"}
|
|
32
|
+
* </Button>
|
|
33
|
+
* );
|
|
34
|
+
* }
|
|
35
|
+
* ```
|
|
13
36
|
*/
|
|
14
37
|
export function useCopyToClipboard(resetMs = 1500): CopyToClipboard {
|
|
15
38
|
const [copied, setCopied] = useState(false);
|
package/src/hooks/use-device.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { useMediaQuery } from "./use-media-query";
|
|
2
2
|
|
|
3
|
+
/** The three layout classes: `mobile` under 768px, `tablet` for a coarse pointer above that, `desktop` otherwise. */
|
|
3
4
|
export type DeviceType = "mobile" | "tablet" | "desktop";
|
|
4
5
|
|
|
6
|
+
/** The primary input behind the pointer: `touch` for a coarse pointer, `pointer` for a fine one. */
|
|
5
7
|
export type DeviceInput = "touch" | "pointer";
|
|
6
8
|
|
|
9
|
+
/** What the layout and the key hints need: the device class, the input behind it and whether a key chip can be reached. */
|
|
7
10
|
export type Device = {
|
|
8
11
|
/** `mobile` below 768px, `tablet` for a coarse pointer above that, `desktop` otherwise. */
|
|
9
12
|
type: DeviceType;
|
|
13
|
+
/** `touch` when the pointer is coarse, `pointer` when it is fine. */
|
|
10
14
|
input: DeviceInput;
|
|
11
15
|
/** True only for a hovering fine pointer, the input that can actually reach a key chip. */
|
|
12
16
|
keyboardHints: boolean;
|
|
@@ -31,7 +35,31 @@ function deviceSnapshot(type: DeviceType, input: DeviceInput, keyboardHints: boo
|
|
|
31
35
|
return next;
|
|
32
36
|
}
|
|
33
37
|
|
|
34
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* The device class and its primary input, as the layout and key hints need them.
|
|
40
|
+
*
|
|
41
|
+
* The three queries go through {@link useMediaQuery}, so nothing is read during a server render or the first
|
|
42
|
+
* hydration pass: that pass reports `desktop` / `pointer` with `keyboardHints` on, the value the query hooks
|
|
43
|
+
* were given as their server fallback. The returned object is shared, so a re-render with the same device
|
|
44
|
+
* hands back the same reference.
|
|
45
|
+
*
|
|
46
|
+
* @returns The current `Device`: `type`, `input` and `keyboardHints`.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```tsx
|
|
50
|
+
* import { useDevice } from "@horon-ai/react";
|
|
51
|
+
*
|
|
52
|
+
* function ComposeFooter() {
|
|
53
|
+
* const { type, keyboardHints } = useDevice();
|
|
54
|
+
* return (
|
|
55
|
+
* <footer className="flex items-center gap-2">
|
|
56
|
+
* <span className="text-caption">{type}</span>
|
|
57
|
+
* {keyboardHints && <Kbd keys="Mod+Enter" />}
|
|
58
|
+
* </footer>
|
|
59
|
+
* );
|
|
60
|
+
* }
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
35
63
|
export function useDevice(): Device {
|
|
36
64
|
const mobile = useMediaQuery(MOBILE_QUERY);
|
|
37
65
|
const coarse = useMediaQuery(COARSE_QUERY);
|
package/src/hooks/use-hotkey.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { useEffect, useRef } from "react";
|
|
|
3
3
|
import { matchesShortcut } from "../lib/platform";
|
|
4
4
|
import { usePlatform } from "./use-platform";
|
|
5
5
|
|
|
6
|
+
/** Options of {@link useHotkey}: the only switch is whether the listener is attached. */
|
|
6
7
|
export type HotkeyOptions = {
|
|
7
8
|
/** When false the listener is detached and the hotkey does nothing (default true). */
|
|
8
9
|
enabled?: boolean;
|
|
@@ -12,6 +13,26 @@ export type HotkeyOptions = {
|
|
|
12
13
|
* Runs `handler` on a `document` keydown that matches `keys`, such as `Mod+K`,
|
|
13
14
|
* and prevents the browser default. Text inputs keep their focus, so a palette
|
|
14
15
|
* shortcut still fires mid-typing; the check follows the OS modifier key.
|
|
16
|
+
*
|
|
17
|
+
* The listener is attached in an effect, so a server render binds nothing. The handler is held in a ref, so a
|
|
18
|
+
* caller can pass an inline arrow without re-binding on every render; only `keys`, `enabled` and the OS
|
|
19
|
+
* re-subscribe. `Mod` follows {@link usePlatform}: ⌘ on macOS and iOS, Ctrl on Windows, Linux and Android,
|
|
20
|
+
* and either one while the OS is still unknown.
|
|
21
|
+
*
|
|
22
|
+
* @param keys - Shortcut string such as `Mod+K`, `Mod+Shift+Enter` or `Escape`, in the same syntax `Kbd` draws.
|
|
23
|
+
* @param handler - Called with the matching keydown event after `preventDefault()`.
|
|
24
|
+
* @param options - {@link HotkeyOptions}; `enabled` defaults to `true`.
|
|
25
|
+
* @returns Nothing; the listener is cleaned up when the component unmounts or `keys`/`enabled` change.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```tsx
|
|
29
|
+
* import { useHotkey } from "@horon-ai/react";
|
|
30
|
+
*
|
|
31
|
+
* function PaletteShortcut({ open }: { open: () => void }) {
|
|
32
|
+
* useHotkey("Mod+K", open);
|
|
33
|
+
* return <Kbd keys="Mod+K" />;
|
|
34
|
+
* }
|
|
35
|
+
* ```
|
|
15
36
|
*/
|
|
16
37
|
export function useHotkey(keys: string, handler: (event: KeyboardEvent) => void, options: HotkeyOptions = {}) {
|
|
17
38
|
const { enabled = true } = options;
|
|
@@ -6,6 +6,24 @@ const noop = () => {};
|
|
|
6
6
|
* Tracks one `matchMedia` query. The server render and the first client render
|
|
7
7
|
* use `serverValue`, so static thumbnails and hydration agree before the browser
|
|
8
8
|
* reports the real state.
|
|
9
|
+
*
|
|
10
|
+
* The subscription is rebuilt when `query` changes, and both the server snapshot and the snapshot taken before
|
|
11
|
+
* `window.matchMedia` exists fall back to `serverValue`. The query string must be a literal the caller keeps
|
|
12
|
+
* stable across renders, as the cache key is the string itself.
|
|
13
|
+
*
|
|
14
|
+
* @param query - A media query such as `(max-width: 767px)` or `(prefers-reduced-motion: reduce)`.
|
|
15
|
+
* @param serverValue - Result to report while rendering on the server or before `matchMedia` is available. @default false
|
|
16
|
+
* @returns Whether the query currently matches, or `serverValue` on the server and in the first pass.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```tsx
|
|
20
|
+
* import { useMediaQuery } from "@horon-ai/react";
|
|
21
|
+
*
|
|
22
|
+
* function Nav() {
|
|
23
|
+
* const compact = useMediaQuery("(max-width: 767px)");
|
|
24
|
+
* return compact ? <TabBar items={items} value={tab} onValueChange={setTab} /> : <Sidebar items={items} />;
|
|
25
|
+
* }
|
|
26
|
+
* ```
|
|
9
27
|
*/
|
|
10
28
|
export function useMediaQuery(query: string, serverValue = false): boolean {
|
|
11
29
|
const subscribe = useCallback(
|
|
@@ -2,10 +2,14 @@ import { useSyncExternalStore } from "react";
|
|
|
2
2
|
|
|
3
3
|
import { detectOs, detectRuntime, readNavigator, type Os, type Runtime } from "../lib/platform";
|
|
4
4
|
|
|
5
|
+
/** Which physical key carries `Mod` on this OS: `meta` is ⌘, `ctrl` is Ctrl. */
|
|
5
6
|
export type ModKey = "meta" | "ctrl";
|
|
6
7
|
|
|
8
|
+
/** What {@link usePlatform} reports: the OS, the shell the page runs in and the key behind `Mod`. */
|
|
7
9
|
export type Platform = {
|
|
10
|
+
/** The detected OS, `unknown` on the server and when no hint names one. */
|
|
8
11
|
os: Os;
|
|
12
|
+
/** `desktop` inside the Tauri shell or Electron, `web` everywhere else including the server. */
|
|
9
13
|
runtime: Runtime;
|
|
10
14
|
/** The physical key behind `Mod` on this OS, ready for a keydown check. */
|
|
11
15
|
modKey: ModKey;
|
|
@@ -33,7 +37,32 @@ function readPlatform(): Platform {
|
|
|
33
37
|
|
|
34
38
|
const subscribe = (_onChange: () => void) => noop;
|
|
35
39
|
|
|
36
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* The current OS, browser or desktop shell, and the key that means `Mod`.
|
|
42
|
+
*
|
|
43
|
+
* The value is read from `navigator` and `window` and cached for the life of the page: there is no store to
|
|
44
|
+
* subscribe to, so the hook never re-renders on its own. A server render and the first hydration pass get
|
|
45
|
+
* `{ os: "unknown", runtime: "web", modKey: "meta" }`, which draws the Mac keys; on the client the first render
|
|
46
|
+
* already reports the detected platform.
|
|
47
|
+
*
|
|
48
|
+
* @returns The current {@link Platform}: `os`, `runtime` and `modKey`.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```tsx
|
|
52
|
+
* import { isModKey, usePlatform } from "@horon-ai/react";
|
|
53
|
+
*
|
|
54
|
+
* function Composer({ onStart }: { onStart: () => void }) {
|
|
55
|
+
* const { os } = usePlatform();
|
|
56
|
+
* return (
|
|
57
|
+
* <textarea
|
|
58
|
+
* onKeyDown={(event) => {
|
|
59
|
+
* if (event.key === "Enter" && isModKey(event, os)) onStart();
|
|
60
|
+
* }}
|
|
61
|
+
* />
|
|
62
|
+
* );
|
|
63
|
+
* }
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
37
66
|
export function usePlatform(): Platform {
|
|
38
67
|
return useSyncExternalStore(subscribe, readPlatform, () => SERVER_PLATFORM);
|
|
39
68
|
}
|
|
@@ -2,7 +2,24 @@ import { useMediaQuery } from "./use-media-query";
|
|
|
2
2
|
|
|
3
3
|
const QUERY = "(prefers-reduced-motion: reduce)";
|
|
4
4
|
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* True while the OS asks for reduced motion; false on the server.
|
|
7
|
+
*
|
|
8
|
+
* A thin reading of {@link useMediaQuery} over `(prefers-reduced-motion: reduce)`; the CSS side of the same
|
|
9
|
+
* preference is handled by the motion utilities in `globals.css`, which collapse their durations.
|
|
10
|
+
*
|
|
11
|
+
* @returns Whether the user prefers reduced motion; `false` during a server render and the first pass.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```tsx
|
|
15
|
+
* import { useReducedMotion } from "@horon-ai/react";
|
|
16
|
+
*
|
|
17
|
+
* function Stream({ text }: { text: string }) {
|
|
18
|
+
* const reducedMotion = useReducedMotion();
|
|
19
|
+
* return <StreamingText text={text} smooth={!reducedMotion} />;
|
|
20
|
+
* }
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
6
23
|
export function useReducedMotion(): boolean {
|
|
7
24
|
return useMediaQuery(QUERY);
|
|
8
25
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Horon Design System for React.
|
|
3
|
+
*
|
|
4
|
+
* The package holds the 84 Horon components ported from the Figma `Horon` file. Behaviour and
|
|
5
|
+
* accessibility come from Base UI (`@base-ui/react`); layout and colour come from Tailwind CSS v4
|
|
6
|
+
* utilities whose values are the `@horon-ai/tokens` variables, so class names such as `text-label-md`
|
|
7
|
+
* or `bg-glass-regular` are the design tokens themselves. TypeScript source ships as-is, with no
|
|
8
|
+
* build output, so the consuming bundler compiles it.
|
|
9
|
+
*
|
|
10
|
+
* Import everything from the barrel, or one component from its subpath:
|
|
11
|
+
*
|
|
12
|
+
* - `import { Button, Card, Input } from "@horon-ai/react"` re-exports every component and its prop types.
|
|
13
|
+
* - `import { Button } from "@horon-ai/react/components/button"` resolves to the same source file
|
|
14
|
+
* through the `./components/*` export map, which keeps a bundle to the components it uses.
|
|
15
|
+
* - `import { cn } from "@horon-ai/react"` is the tailwind-merge helper that understands the token
|
|
16
|
+
* names, so a caller class overrides a component default.
|
|
17
|
+
* - `import { mountCursorGhost } ...` / `import "@horon-ai/react/cursor-ghost"` adds the desktop
|
|
18
|
+
* cursor trail outside React.
|
|
19
|
+
* - `import { usePlatform, useDevice, useHotkey } from "@horon-ai/react"` reads the OS, the browser or
|
|
20
|
+
* desktop shell and the input device, and binds a `Mod+K`-style shortcut. The hooks are barrel-only,
|
|
21
|
+
* with no subpath, and the same `lib/platform` helpers (`formatShortcut`, `matchesShortcut`, `isModKey`)
|
|
22
|
+
* are exported so a caller's shortcut draws and matches the keys the components use.
|
|
23
|
+
*
|
|
24
|
+
* Import `@horon-ai/react/globals.css` once in the app entry stylesheet: it brings in Tailwind CSS v4,
|
|
25
|
+
* Pretendard, Geist Mono, the generated token theme and the liquid-glass layer styles. Add an `@source`
|
|
26
|
+
* line beside it that points at the package `src` folder so Tailwind scans the component sources (see
|
|
27
|
+
* the README for the exact line). Then set `class="dark"` on an ancestor for the dark palette, and
|
|
28
|
+
* `data-platform="desktop"` or `data-platform="mobile"` to switch the typography board.
|
|
29
|
+
*
|
|
30
|
+
* @packageDocumentation
|
|
31
|
+
*/
|
|
1
32
|
export * from "./components/agent-activity";
|
|
2
33
|
export * from "./components/agent-lane";
|
|
3
34
|
export * from "./components/agent-lanes-timeline";
|
package/src/lib/cn.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { lightColors, primitives, radius, shadow, textStyles } from "@horon-ai/tokens";
|
|
2
2
|
import { extendTailwindMerge } from "tailwind-merge";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* One class-name argument accepted by {@link cn}: a class string, or a falsy value that is skipped.
|
|
6
|
+
* `false`, `null` and `undefined` are common when a class is applied conditionally.
|
|
7
|
+
*/
|
|
4
8
|
export type ClassValue = string | false | null | undefined;
|
|
5
9
|
|
|
6
10
|
/**
|
|
@@ -19,7 +23,15 @@ const merge = extendTailwindMerge({
|
|
|
19
23
|
},
|
|
20
24
|
});
|
|
21
25
|
|
|
22
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Joins class names, skipping empty values; later classes win over conflicting earlier ones.
|
|
28
|
+
*
|
|
29
|
+
* Use it in every component so a caller's class overrides the component default instead of
|
|
30
|
+
* duplicating it; the merge is extended with the Horon token names.
|
|
31
|
+
*
|
|
32
|
+
* @param values - Any number of class strings or falsy values, applied left to right.
|
|
33
|
+
* @returns One space-separated class string with conflicting utilities collapsed.
|
|
34
|
+
*/
|
|
23
35
|
export function cn(...values: ClassValue[]): string {
|
|
24
36
|
return merge(values.filter(Boolean).join(" "));
|
|
25
37
|
}
|
package/src/lib/cursor-ghost.ts
CHANGED
|
@@ -12,15 +12,15 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
export type CursorGhostOptions = {
|
|
15
|
-
/** Number of arrow copies. */
|
|
15
|
+
/** Number of arrow copies. @default 5 */
|
|
16
16
|
ghosts?: number;
|
|
17
|
-
/** Arrow height in CSS px. */
|
|
17
|
+
/** Arrow height in CSS px. @default 26 */
|
|
18
18
|
size?: number;
|
|
19
|
-
/** Lerp factor of the first copy toward the pointer, per frame. */
|
|
19
|
+
/** Lerp factor of the first copy toward the pointer, per frame. @default 0.16 */
|
|
20
20
|
headFollow?: number;
|
|
21
|
-
/** Blur spread of the last copy in CSS px. */
|
|
21
|
+
/** Blur spread of the last copy in CSS px. @default 14 */
|
|
22
22
|
smear?: number;
|
|
23
|
-
/** Arrow fill. Blended with `difference`, so white reads dark on light pages. */
|
|
23
|
+
/** Arrow fill. Blended with `difference`, so white reads dark on light pages. @default "#FFFFFF" */
|
|
24
24
|
color?: string;
|
|
25
25
|
};
|
|
26
26
|
|
|
@@ -195,6 +195,13 @@ function startEffect({ ghosts, size, headFollow, smear, color }: Required<Cursor
|
|
|
195
195
|
/**
|
|
196
196
|
* Mounts the Cursor Ghost trail and returns its teardown. The trail appears and
|
|
197
197
|
* disappears on its own as the pointer type or the reduced-motion setting changes.
|
|
198
|
+
*
|
|
199
|
+
* Safe to call during server rendering: without a `window`, `document` or `matchMedia`
|
|
200
|
+
* it returns a no-op teardown. Call it after `<html data-horon-cursor="off">` is set when
|
|
201
|
+
* you want both the automatic mount suppressed and custom options.
|
|
202
|
+
*
|
|
203
|
+
* @param options - Overrides for the trail; omitted keys fall back to the built-in defaults.
|
|
204
|
+
* @returns A teardown that removes the layer, its listeners and any pending animation frame.
|
|
198
205
|
*/
|
|
199
206
|
export function mountCursorGhost(options: CursorGhostOptions = {}): () => void {
|
|
200
207
|
if (typeof window === "undefined" || typeof document === "undefined" || !window.matchMedia) return () => {};
|
|
@@ -234,7 +241,10 @@ export function mountCursorGhost(options: CursorGhostOptions = {}): () => void {
|
|
|
234
241
|
|
|
235
242
|
function autoMount() {
|
|
236
243
|
if (typeof window === "undefined" || typeof document === "undefined") return;
|
|
237
|
-
const registry = globalThis as typeof globalThis & {
|
|
244
|
+
const registry = globalThis as typeof globalThis & {
|
|
245
|
+
/** Set once the automatic mount has run, so a second import does not add another trail. */
|
|
246
|
+
[AUTO_MOUNT_KEY]?: true;
|
|
247
|
+
};
|
|
238
248
|
if (registry[AUTO_MOUNT_KEY]) return;
|
|
239
249
|
if (document.documentElement.getAttribute(OPT_OUT_ATTRIBUTE) === "off") return;
|
|
240
250
|
registry[AUTO_MOUNT_KEY] = true;
|
package/src/lib/platform.ts
CHANGED
|
@@ -1,41 +1,69 @@
|
|
|
1
|
+
/** The operating systems the shortcut helpers know; `unknown` is what a server render and an empty hint report. */
|
|
1
2
|
export type Os = "macos" | "windows" | "linux" | "ios" | "android" | "unknown";
|
|
2
3
|
|
|
4
|
+
/** Where the page runs: `desktop` inside the Tauri shell or Electron, `web` everywhere else including the server. */
|
|
3
5
|
export type Runtime = "web" | "desktop";
|
|
4
6
|
|
|
5
7
|
/** The parts of `navigator` that identify the OS; plain data so detection stays testable. */
|
|
6
8
|
export type PlatformInput = {
|
|
9
|
+
/** Full user agent string, the last and coarsest hint. */
|
|
7
10
|
userAgent?: string;
|
|
11
|
+
/** `navigator.platform`, still the most reliable hint in Safari and Firefox. */
|
|
8
12
|
platform?: string;
|
|
13
|
+
/** `navigator.userAgentData.platform`, the first hint when the browser exposes it. */
|
|
9
14
|
uaPlatform?: string;
|
|
15
|
+
/** Touch points, which is what separates iPadOS (a Mac hint) from a Mac. */
|
|
10
16
|
maxTouchPoints?: number;
|
|
11
17
|
};
|
|
12
18
|
|
|
13
19
|
/** The parts of `window` that tell a page from the desktop shell; plain data for the same reason. */
|
|
14
20
|
export type RuntimeWindow = {
|
|
21
|
+
/** Set by Tauri in the desktop shell. */
|
|
15
22
|
__TAURI_INTERNALS__?: unknown;
|
|
16
|
-
navigator
|
|
23
|
+
/** `window.navigator`, read for the Electron fragment in its user agent. */
|
|
24
|
+
navigator?: {
|
|
25
|
+
/** User agent string, tested for `electron`. */
|
|
26
|
+
userAgent?: string;
|
|
27
|
+
};
|
|
17
28
|
};
|
|
18
29
|
|
|
19
30
|
/** The parts of a keyboard event a shortcut reads; `KeyboardEvent` satisfies it as-is. */
|
|
20
31
|
export type ShortcutEvent = {
|
|
32
|
+
/** `KeyboardEvent.key`, for example `k`, `Enter` or `ArrowUp`. */
|
|
21
33
|
key?: string;
|
|
34
|
+
/** ⌘ on macOS and iOS. */
|
|
22
35
|
metaKey?: boolean;
|
|
36
|
+
/** Ctrl on Windows, Linux and Android. */
|
|
23
37
|
ctrlKey?: boolean;
|
|
38
|
+
/** ⌥ or Alt. */
|
|
24
39
|
altKey?: boolean;
|
|
40
|
+
/** ⇧ or Shift. */
|
|
25
41
|
shiftKey?: boolean;
|
|
26
42
|
};
|
|
27
43
|
|
|
28
44
|
/**
|
|
29
45
|
* Reads the platform hints from the live `navigator`. `userAgentData` is not in
|
|
30
46
|
* the DOM typings yet, so the shape is spelled out here instead of cast away.
|
|
47
|
+
*
|
|
48
|
+
* Every field is copied out, and a missing `navigator` (a server render) yields an empty object, so
|
|
49
|
+
* {@link detectOs} falls through to `unknown` instead of throwing.
|
|
50
|
+
*
|
|
51
|
+
* @returns The four hints as {@link PlatformInput}; fields the browser does not expose stay `undefined`.
|
|
31
52
|
*/
|
|
32
53
|
export function readNavigator(): PlatformInput {
|
|
33
54
|
if (typeof navigator === "undefined") return {};
|
|
34
55
|
const nav = navigator as {
|
|
56
|
+
/** Full user agent string. */
|
|
35
57
|
userAgent?: string;
|
|
58
|
+
/** `navigator.platform`, such as `MacIntel`. */
|
|
36
59
|
platform?: string;
|
|
60
|
+
/** Number of touch points, which tells iPadOS from a Mac. */
|
|
37
61
|
maxTouchPoints?: number;
|
|
38
|
-
|
|
62
|
+
/** Chromium's hint object, absent in Safari and Firefox. */
|
|
63
|
+
userAgentData?: {
|
|
64
|
+
/** The OS hint, such as `macOS` or `Windows`. */
|
|
65
|
+
platform?: string;
|
|
66
|
+
};
|
|
39
67
|
};
|
|
40
68
|
return {
|
|
41
69
|
userAgent: nav.userAgent,
|
|
@@ -62,6 +90,13 @@ function osFromHint(hint: string, maxTouchPoints: number): Os | undefined {
|
|
|
62
90
|
* Finds the OS from the first hint that names one: `userAgentData.platform`,
|
|
63
91
|
* then `navigator.platform`, then the user agent string. Empty input is
|
|
64
92
|
* `unknown`, which the shortcuts treat as the macOS/⌘ rendering.
|
|
93
|
+
*
|
|
94
|
+
* A hint containing `mac` is `ios` when `maxTouchPoints` is above 1, since iPadOS reports a Mac platform and
|
|
95
|
+
* user agent. A hint that matches nothing is skipped, so a browser that names no known OS still reports
|
|
96
|
+
* `unknown` rather than a wrong guess.
|
|
97
|
+
*
|
|
98
|
+
* @param input - The hints to read, usually from {@link readNavigator}; omitted means empty. @default {}
|
|
99
|
+
* @returns The first `Os` a hint names, or `unknown` when none does.
|
|
65
100
|
*/
|
|
66
101
|
export function detectOs(input: PlatformInput = {}): Os {
|
|
67
102
|
const maxTouchPoints = input.maxTouchPoints ?? 0;
|
|
@@ -74,7 +109,14 @@ export function detectOs(input: PlatformInput = {}): Os {
|
|
|
74
109
|
return "unknown";
|
|
75
110
|
}
|
|
76
111
|
|
|
77
|
-
/**
|
|
112
|
+
/**
|
|
113
|
+
* `desktop` for the Tauri shell and Electron; `web` everywhere else, including the server.
|
|
114
|
+
*
|
|
115
|
+
* The Tauri test wins over the user agent, so a shell that also advertises Electron still reports `desktop`.
|
|
116
|
+
*
|
|
117
|
+
* @param win - The window to inspect, usually `window`; `null` or omitted reports `web`. @default null
|
|
118
|
+
* @returns `desktop` inside either shell, `web` otherwise.
|
|
119
|
+
*/
|
|
78
120
|
export function detectRuntime(win: RuntimeWindow | null = null): Runtime {
|
|
79
121
|
if (!win) return "web";
|
|
80
122
|
if ("__TAURI_INTERNALS__" in win) return "desktop";
|
|
@@ -110,6 +152,13 @@ const OTHER_LABELS: Record<string, string> = {
|
|
|
110
152
|
/**
|
|
111
153
|
* Draws a `+`-joined shortcut for one OS: `Mod+K` is `⌘K` on macOS and iOS,
|
|
112
154
|
* `Ctrl+K` on Windows and Linux. Mac families glue the parts, the rest keep `+`.
|
|
155
|
+
*
|
|
156
|
+
* `mod`, `shift`, `alt`, `enter`, `escape`/`esc` and the four arrows have labels; any other part is kept as
|
|
157
|
+
* written, so a bare `K` stays `K`. An empty string, or one made only of `+` and spaces, returns `""`.
|
|
158
|
+
*
|
|
159
|
+
* @param keys - Shortcut in the same syntax `Kbd` and {@link matchesShortcut} take, such as `Mod+Shift+Enter`.
|
|
160
|
+
* @param os - The OS to draw for; `unknown` draws the Mac symbols, the server default.
|
|
161
|
+
* @returns The label to render, for example `⌘⇧↵` or `Ctrl+Shift+Enter`.
|
|
113
162
|
*/
|
|
114
163
|
export function formatShortcut(keys: string, os: Os): string {
|
|
115
164
|
const parts = keys
|
|
@@ -130,7 +179,16 @@ function modifierSlot(os: Os): "meta" | "ctrl" | "either" {
|
|
|
130
179
|
return "either";
|
|
131
180
|
}
|
|
132
181
|
|
|
133
|
-
/**
|
|
182
|
+
/**
|
|
183
|
+
* True when the platform's `Mod` key is down; the other side's key is not `Mod`.
|
|
184
|
+
*
|
|
185
|
+
* On macOS and iOS that means `metaKey` without `ctrlKey`; on Windows, Linux and Android `ctrlKey` without
|
|
186
|
+
* `metaKey`, so Ctrl+⌘ on a Mac is not `Mod`. With an `unknown` OS either key counts.
|
|
187
|
+
*
|
|
188
|
+
* @param event - The keyboard event or anything shaped like {@link ShortcutEvent}.
|
|
189
|
+
* @param os - The OS whose `Mod` to test, usually from {@link detectOs} or `usePlatform().os`.
|
|
190
|
+
* @returns Whether this event carries the platform's modifier.
|
|
191
|
+
*/
|
|
134
192
|
export function isModKey(event: ShortcutEvent, os: Os): boolean {
|
|
135
193
|
const slot = modifierSlot(os);
|
|
136
194
|
const meta = event.metaKey === true;
|
|
@@ -154,6 +212,15 @@ function canonicalKey(value: string): string {
|
|
|
154
212
|
* Tests a keyboard event against a shortcut string such as `Mod+K` or
|
|
155
213
|
* `Mod+Shift+Enter`. `Mod` is the platform key (⌘ on macOS/iOS, Ctrl
|
|
156
214
|
* elsewhere, either one when the OS is unknown).
|
|
215
|
+
*
|
|
216
|
+
* Matching is exact on the modifier set: Alt and Shift must be present or absent exactly as the shortcut says,
|
|
217
|
+
* and a missing base key (a string of modifiers only) never matches. `esc` and `escape` are the same key, and
|
|
218
|
+
* the comparison is case-insensitive.
|
|
219
|
+
*
|
|
220
|
+
* @param event - The keydown event or anything shaped like {@link ShortcutEvent}.
|
|
221
|
+
* @param keys - Shortcut string, with `mod` standing for the platform key, such as `Mod+K`.
|
|
222
|
+
* @param os - The OS whose `Mod` to test.
|
|
223
|
+
* @returns Whether the event is exactly this shortcut on this OS.
|
|
157
224
|
*/
|
|
158
225
|
export function matchesShortcut(event: ShortcutEvent, keys: string, os: Os): boolean {
|
|
159
226
|
const parts = keys
|