create-arsh-tauri 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +55 -2
- package/index.js +239 -0
- package/package.json +43 -4
- package/template/.eslintrc.cjs +18 -0
- package/template/.github/workflows/ci.yml +50 -0
- package/template/AGENTS.md +50 -0
- package/template/README.md +51 -0
- package/template/gitignore +37 -0
- package/template/index.html +12 -0
- package/template/package-lock.json +4454 -0
- package/template/package.json +42 -0
- package/template/src/App.tsx +35 -0
- package/template/src/index.css +8 -0
- package/template/src/lib/tauri.ts +21 -0
- package/template/src/lib/utils.test.ts +17 -0
- package/template/src/lib/utils.ts +6 -0
- package/template/src/main.tsx +10 -0
- package/template/src/ui/appLayout.tsx +35 -0
- package/template/src/ui/button.tsx +63 -0
- package/template/src/ui/dialog.tsx +71 -0
- package/template/src/ui/index.ts +8 -0
- package/template/src/ui/navItem.tsx +37 -0
- package/template/src/ui/spinner.tsx +33 -0
- package/template/src/ui/styles.css +96 -0
- package/template/src/ui/switch.tsx +77 -0
- package/template/src/ui/titleBar.tsx +157 -0
- package/template/src/ui/tooltip.tsx +141 -0
- package/template/src/vite-env.d.ts +1 -0
- package/template/src-tauri/Cargo.lock +4316 -0
- package/template/src-tauri/Cargo.toml +26 -0
- package/template/src-tauri/build.rs +3 -0
- package/template/src-tauri/capabilities/default.json +16 -0
- package/template/src-tauri/icons/128x128.png +0 -0
- package/template/src-tauri/icons/128x128@2x.png +0 -0
- package/template/src-tauri/icons/32x32.png +0 -0
- package/template/src-tauri/icons/icon.icns +0 -0
- package/template/src-tauri/icons/icon.ico +0 -0
- package/template/src-tauri/src/lib.rs +14 -0
- package/template/src-tauri/src/main.rs +6 -0
- package/template/src-tauri/tauri.conf.json +40 -0
- package/template/temp/.gitkeep +0 -0
- package/template/tsconfig.json +25 -0
- package/template/tsconfig.node.json +12 -0
- package/template/vite.config.ts +24 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "arsh-tauri-app",
|
|
3
|
+
"description": "Tauri + React + TypeScript desktop app",
|
|
4
|
+
"author": "Your Name",
|
|
5
|
+
"private": true,
|
|
6
|
+
"version": "0.1.0",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"dev": "tauri dev",
|
|
10
|
+
"dev:web": "vite",
|
|
11
|
+
"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && npm run build:web && tauri build",
|
|
12
|
+
"build:web": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc && vite build",
|
|
13
|
+
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
|
14
|
+
"test": "vitest run",
|
|
15
|
+
"preview": "vite preview",
|
|
16
|
+
"tauri": "tauri"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@tailwindcss/vite": "^4.3.3",
|
|
21
|
+
"@tauri-apps/api": "^2.0.0",
|
|
22
|
+
"@tauri-apps/cli": "^2.0.0",
|
|
23
|
+
"@types/node": "^25.8.0",
|
|
24
|
+
"@types/react": "^19.2.2",
|
|
25
|
+
"@types/react-dom": "^19.2.2",
|
|
26
|
+
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
|
27
|
+
"@typescript-eslint/parser": "^8.59.3",
|
|
28
|
+
"@vitejs/plugin-react": "^6.1.1",
|
|
29
|
+
"clsx": "^2.1.1",
|
|
30
|
+
"eslint": "^8.57.0",
|
|
31
|
+
"eslint-plugin-react-hooks": "^7.1.1",
|
|
32
|
+
"eslint-plugin-react-refresh": "^0.4.5",
|
|
33
|
+
"lucide-react": "^1.48.0",
|
|
34
|
+
"react": "^19.2.6",
|
|
35
|
+
"react-dom": "^19.2.6",
|
|
36
|
+
"tailwind-merge": "^3.6.0",
|
|
37
|
+
"tailwindcss": "^4.3.3",
|
|
38
|
+
"typescript": "^6.0.3",
|
|
39
|
+
"vite": "^8.0.0",
|
|
40
|
+
"vitest": "^4.0.14"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { invoke } from '@tauri-apps/api/core'
|
|
3
|
+
import { AppLayout, TitleBar, Button } from './ui'
|
|
4
|
+
import { hasNativeBridge } from './lib/tauri'
|
|
5
|
+
|
|
6
|
+
export default function App() {
|
|
7
|
+
const [count, setCount] = useState(0)
|
|
8
|
+
const [greetMsg, setGreetMsg] = useState('')
|
|
9
|
+
|
|
10
|
+
async function greet() {
|
|
11
|
+
if (!hasNativeBridge) return
|
|
12
|
+
setGreetMsg(await invoke<string>('greet', { name: 'Arsh' }))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<AppLayout titleBar={<TitleBar brand="Arsh Tauri" />}>
|
|
17
|
+
<div className="h-full flex flex-col items-center justify-center gap-4 text-center px-6">
|
|
18
|
+
<h1 className="text-3xl font-semibold text-main">Congratulations!</h1>
|
|
19
|
+
<p className="text-sm text-muted max-w-md leading-relaxed">
|
|
20
|
+
Your Tauri project has been initialized. Open this project with any AI
|
|
21
|
+
coding tool and build your own marvelous Tauri app.
|
|
22
|
+
</p>
|
|
23
|
+
<div className="flex items-center gap-2">
|
|
24
|
+
<Button onClick={() => setCount((c) => c + 1)}>count is {count}</Button>
|
|
25
|
+
<Button variant="secondary" onClick={greet}>greet from Rust</Button>
|
|
26
|
+
</div>
|
|
27
|
+
{greetMsg && <p className="text-xs text-dim">{greetMsg}</p>}
|
|
28
|
+
<p className="text-xs text-dim">
|
|
29
|
+
Edit src/App.tsx and save to see hot reload. The Rust backend lives in
|
|
30
|
+
src-tauri/.
|
|
31
|
+
</p>
|
|
32
|
+
</div>
|
|
33
|
+
</AppLayout>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native (Tauri) bridge detection.
|
|
3
|
+
*
|
|
4
|
+
* UI primitives must render correctly in plain web apps too. Anything that
|
|
5
|
+
* needs the native window (title-bar controls, drag regions) checks this
|
|
6
|
+
* first and degrades gracefully when it is absent.
|
|
7
|
+
*/
|
|
8
|
+
import { getCurrentWindow, type Window } from '@tauri-apps/api/window'
|
|
9
|
+
|
|
10
|
+
export const hasNativeBridge =
|
|
11
|
+
typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
|
12
|
+
|
|
13
|
+
/** The focused app window, or null when running as a plain web page. */
|
|
14
|
+
export function getWindow(): Window | null {
|
|
15
|
+
if (!hasNativeBridge) return null
|
|
16
|
+
try {
|
|
17
|
+
return getCurrentWindow()
|
|
18
|
+
} catch {
|
|
19
|
+
return null
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { cn } from './utils'
|
|
3
|
+
|
|
4
|
+
describe('cn', () => {
|
|
5
|
+
it('joins conditional classes', () => {
|
|
6
|
+
expect(cn('flex', false && 'hidden', 'items-center')).toBe('flex items-center')
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('lets later tailwind classes win conflicts', () => {
|
|
10
|
+
expect(cn('px-2', 'px-4')).toBe('px-4')
|
|
11
|
+
expect(cn('text-sm text-main', 'text-lg')).toBe('text-main text-lg')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('resolves conflicting class groups, not just identical classes', () => {
|
|
15
|
+
expect(cn('p-2', 'py-4')).toBe('p-2 py-4')
|
|
16
|
+
})
|
|
17
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { cn } from '../lib/utils'
|
|
3
|
+
|
|
4
|
+
interface AppLayoutProps {
|
|
5
|
+
/** The TitleBar component */
|
|
6
|
+
titleBar: ReactNode
|
|
7
|
+
/** Main content area */
|
|
8
|
+
children: ReactNode
|
|
9
|
+
/** Optional sidebar rendered between title bar and content */
|
|
10
|
+
sidebar?: ReactNode
|
|
11
|
+
className?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* AppLayout provides the top-level shell for an application.
|
|
16
|
+
* It stacks the title bar, optional sidebar, and main content vertically.
|
|
17
|
+
* Height follows the parent (h-full): use 100vh on the root container (e.g. #root).
|
|
18
|
+
*/
|
|
19
|
+
export function AppLayout({ titleBar, children, sidebar, className }: AppLayoutProps) {
|
|
20
|
+
return (
|
|
21
|
+
<div className={cn('h-full flex flex-col overflow-hidden bg-page', className)}>
|
|
22
|
+
{titleBar}
|
|
23
|
+
<div className="flex-1 flex overflow-hidden">
|
|
24
|
+
{sidebar && (
|
|
25
|
+
<div className="shrink-0 border-r border-line">
|
|
26
|
+
{sidebar}
|
|
27
|
+
</div>
|
|
28
|
+
)}
|
|
29
|
+
<main className="flex-1 overflow-auto">
|
|
30
|
+
{children}
|
|
31
|
+
</main>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type ReactNode, type ButtonHTMLAttributes } from 'react'
|
|
2
|
+
import { cn } from '../lib/utils'
|
|
3
|
+
import { Loader2 } from 'lucide-react'
|
|
4
|
+
|
|
5
|
+
const variants = {
|
|
6
|
+
default: 'bg-accent text-fg hover:opacity-80',
|
|
7
|
+
secondary: 'bg-surface border border-line text-main hover:bg-hover',
|
|
8
|
+
ghost: 'text-muted hover:text-main hover:bg-hover',
|
|
9
|
+
destructive: 'bg-destructive text-white hover:opacity-80',
|
|
10
|
+
link: 'text-accent underline-offset-4 hover:underline',
|
|
11
|
+
} as const
|
|
12
|
+
|
|
13
|
+
const sizes = {
|
|
14
|
+
sm: 'px-2.5 py-1 text-xs rounded-md',
|
|
15
|
+
md: 'px-4 py-1.5 text-sm rounded-lg',
|
|
16
|
+
lg: 'px-5 py-2 text-sm rounded-lg',
|
|
17
|
+
} as const
|
|
18
|
+
|
|
19
|
+
interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'className'> {
|
|
20
|
+
/** Button visual style */
|
|
21
|
+
variant?: keyof typeof variants
|
|
22
|
+
/** Button size */
|
|
23
|
+
size?: keyof typeof sizes
|
|
24
|
+
/** Optional icon shown before the label */
|
|
25
|
+
icon?: ReactNode
|
|
26
|
+
/** Show loading spinner */
|
|
27
|
+
loading?: boolean
|
|
28
|
+
className?: string
|
|
29
|
+
children?: ReactNode
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function Button({
|
|
33
|
+
variant = 'default',
|
|
34
|
+
size = 'md',
|
|
35
|
+
icon,
|
|
36
|
+
loading = false,
|
|
37
|
+
disabled,
|
|
38
|
+
className,
|
|
39
|
+
children,
|
|
40
|
+
...props
|
|
41
|
+
}: ButtonProps) {
|
|
42
|
+
return (
|
|
43
|
+
<button
|
|
44
|
+
disabled={disabled || loading}
|
|
45
|
+
className={cn(
|
|
46
|
+
'inline-flex items-center justify-center gap-1.5 font-medium transition-all cursor-pointer select-none',
|
|
47
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/20',
|
|
48
|
+
'disabled:opacity-40 disabled:cursor-not-allowed',
|
|
49
|
+
variants[variant],
|
|
50
|
+
sizes[size],
|
|
51
|
+
className,
|
|
52
|
+
)}
|
|
53
|
+
{...props}
|
|
54
|
+
>
|
|
55
|
+
{loading ? (
|
|
56
|
+
<Loader2 className={cn('size-4 animate-spin', children && 'shrink-0')} />
|
|
57
|
+
) : icon ? (
|
|
58
|
+
<span className="size-4 shrink-0 flex items-center justify-center">{icon}</span>
|
|
59
|
+
) : null}
|
|
60
|
+
{children}
|
|
61
|
+
</button>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useEffect, useCallback, type ReactNode } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import { cn } from '../lib/utils'
|
|
4
|
+
|
|
5
|
+
interface DialogProps {
|
|
6
|
+
open: boolean
|
|
7
|
+
onClose: () => void
|
|
8
|
+
children: ReactNode
|
|
9
|
+
/** Whether clicking the overlay closes the dialog */
|
|
10
|
+
closeOnOverlay?: boolean
|
|
11
|
+
/** Whether to close on Escape key */
|
|
12
|
+
closeOnEscape?: boolean
|
|
13
|
+
className?: string
|
|
14
|
+
overlayClassName?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function Dialog({
|
|
18
|
+
open,
|
|
19
|
+
onClose,
|
|
20
|
+
children,
|
|
21
|
+
closeOnOverlay = true,
|
|
22
|
+
closeOnEscape = true,
|
|
23
|
+
className,
|
|
24
|
+
overlayClassName,
|
|
25
|
+
}: DialogProps) {
|
|
26
|
+
const handleKeyDown = useCallback(
|
|
27
|
+
(e: KeyboardEvent) => {
|
|
28
|
+
if (closeOnEscape && e.key === 'Escape') onClose()
|
|
29
|
+
},
|
|
30
|
+
[onClose, closeOnEscape],
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!open) return
|
|
35
|
+
document.addEventListener('keydown', handleKeyDown)
|
|
36
|
+
document.body.style.overflow = 'hidden'
|
|
37
|
+
return () => {
|
|
38
|
+
document.removeEventListener('keydown', handleKeyDown)
|
|
39
|
+
document.body.style.overflow = ''
|
|
40
|
+
}
|
|
41
|
+
}, [open, handleKeyDown])
|
|
42
|
+
|
|
43
|
+
if (!open) return null
|
|
44
|
+
|
|
45
|
+
return createPortal(
|
|
46
|
+
<div
|
|
47
|
+
className={cn(
|
|
48
|
+
'fixed inset-0 z-60 flex items-center justify-center',
|
|
49
|
+
'animate-[fade-in_150ms_ease-out]',
|
|
50
|
+
overlayClassName,
|
|
51
|
+
)}
|
|
52
|
+
style={{ background: 'rgba(0,0,0,0.45)' }}
|
|
53
|
+
onClick={(e) => {
|
|
54
|
+
if (closeOnOverlay && e.target === e.currentTarget) onClose()
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
<div
|
|
58
|
+
role="dialog"
|
|
59
|
+
aria-modal="true"
|
|
60
|
+
className={cn(
|
|
61
|
+
'bg-surface rounded-lg shadow-lg max-w-md w-full mx-4',
|
|
62
|
+
'animate-[scale-in_150ms_ease-out]',
|
|
63
|
+
className,
|
|
64
|
+
)}
|
|
65
|
+
>
|
|
66
|
+
{children}
|
|
67
|
+
</div>
|
|
68
|
+
</div>,
|
|
69
|
+
document.body,
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { AppLayout } from './appLayout'
|
|
2
|
+
export { TitleBar } from './titleBar'
|
|
3
|
+
export { NavItem } from './navItem'
|
|
4
|
+
export { Button } from './button'
|
|
5
|
+
export { Dialog } from './dialog'
|
|
6
|
+
export { Tooltip } from './tooltip'
|
|
7
|
+
export { Spinner } from './spinner'
|
|
8
|
+
export { Switch } from './switch'
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type ReactNode, type MouseEvent } from 'react'
|
|
2
|
+
import { cn } from '../lib/utils'
|
|
3
|
+
|
|
4
|
+
interface NavItemProps {
|
|
5
|
+
children: ReactNode
|
|
6
|
+
className?: string
|
|
7
|
+
/** Optional icon shown before the label */
|
|
8
|
+
icon?: ReactNode
|
|
9
|
+
/** Active (highlighted) state */
|
|
10
|
+
active?: boolean
|
|
11
|
+
onClick?: (e: MouseEvent<HTMLButtonElement>) => void
|
|
12
|
+
title?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Top navigation item, typically used as a child of TitleBar.
|
|
17
|
+
* Not bound to any router: the caller computes `active` and handles navigation.
|
|
18
|
+
*/
|
|
19
|
+
export function NavItem({ children, className, icon, active = false, onClick, title }: NavItemProps) {
|
|
20
|
+
return (
|
|
21
|
+
<button
|
|
22
|
+
onClick={onClick}
|
|
23
|
+
aria-current={active ? 'page' : undefined}
|
|
24
|
+
title={title}
|
|
25
|
+
className={cn(
|
|
26
|
+
'flex items-center gap-1.5 px-3 text-xs font-medium border-r border-line/40 transition-colors cursor-pointer',
|
|
27
|
+
active
|
|
28
|
+
? 'bg-hover text-main'
|
|
29
|
+
: 'text-muted hover:text-main hover:bg-hover/50',
|
|
30
|
+
className,
|
|
31
|
+
)}
|
|
32
|
+
>
|
|
33
|
+
{icon && <span className="size-3.5 flex items-center justify-center">{icon}</span>}
|
|
34
|
+
{children}
|
|
35
|
+
</button>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { cn } from '../lib/utils'
|
|
2
|
+
import { Loader2 } from 'lucide-react'
|
|
3
|
+
|
|
4
|
+
interface SpinnerProps {
|
|
5
|
+
/** Spinner size */
|
|
6
|
+
size?: 'sm' | 'md' | 'lg'
|
|
7
|
+
/** Show label below the spinner */
|
|
8
|
+
label?: string
|
|
9
|
+
className?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const sizes = {
|
|
13
|
+
sm: { icon: 'size-4', label: 'text-xs' },
|
|
14
|
+
md: { icon: 'size-6', label: 'text-sm' },
|
|
15
|
+
lg: { icon: 'size-8', label: 'text-sm' },
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function Spinner({ size = 'md', label, className }: SpinnerProps) {
|
|
19
|
+
const s = sizes[size]
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div className={cn('inline-flex flex-col items-center gap-2', className)}>
|
|
23
|
+
<Loader2
|
|
24
|
+
className={cn('animate-spin text-accent', s.icon)}
|
|
25
|
+
role="status"
|
|
26
|
+
aria-label={label || 'Loading'}
|
|
27
|
+
/>
|
|
28
|
+
{label && (
|
|
29
|
+
<span className={cn('text-muted', s.label)}>{label}</span>
|
|
30
|
+
)}
|
|
31
|
+
</div>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Theme tokens.
|
|
3
|
+
*
|
|
4
|
+
* Every color maps to a plain CSS variable, so apps (and dark themes) can
|
|
5
|
+
* re-skin the whole kit by overriding the `:root` block below.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
@theme inline {
|
|
9
|
+
--color-page: var(--page);
|
|
10
|
+
--color-surface: var(--surface);
|
|
11
|
+
--color-hover: var(--hover);
|
|
12
|
+
--color-accent: var(--accent);
|
|
13
|
+
--color-fg: var(--accent-foreground);
|
|
14
|
+
--color-main: var(--text-primary);
|
|
15
|
+
--color-muted: var(--text-secondary);
|
|
16
|
+
--color-dim: var(--text-tertiary);
|
|
17
|
+
--color-line: var(--border);
|
|
18
|
+
--color-destructive: var(--destructive);
|
|
19
|
+
--color-success: var(--success);
|
|
20
|
+
--color-warning: var(--warning);
|
|
21
|
+
--color-info: var(--info);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
:root {
|
|
25
|
+
--page: #f1f5f9;
|
|
26
|
+
--surface: #ffffff;
|
|
27
|
+
--hover: #e2e8f0;
|
|
28
|
+
--accent: #1e293b;
|
|
29
|
+
--accent-foreground: #ffffff;
|
|
30
|
+
--text-primary: #1e293b;
|
|
31
|
+
--text-secondary: #94a3b8;
|
|
32
|
+
--text-tertiary: #475569;
|
|
33
|
+
--border: #e2e8f0;
|
|
34
|
+
--destructive: #dc2626;
|
|
35
|
+
--success: #16a34a;
|
|
36
|
+
--warning: #d97706;
|
|
37
|
+
--info: #2563eb;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
* {
|
|
41
|
+
border-color: var(--border);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
body {
|
|
45
|
+
margin: 0;
|
|
46
|
+
background: var(--page);
|
|
47
|
+
color: var(--text-primary);
|
|
48
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
49
|
+
-webkit-font-smoothing: antialiased;
|
|
50
|
+
-moz-osx-font-smoothing: grayscale;
|
|
51
|
+
user-select: none;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/*
|
|
55
|
+
* Frameless window dragging: put `data-tauri-drag-region` on the element that
|
|
56
|
+
* should drag the window (see src/ui/titleBar.tsx). Interactive children must
|
|
57
|
+
* not carry the attribute.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
svg:focus { outline: none; }
|
|
61
|
+
svg *:focus { outline: none; }
|
|
62
|
+
|
|
63
|
+
/* Overlay scrim shared by Dialog / Drawer / Toast containers */
|
|
64
|
+
.arshui-scrim {
|
|
65
|
+
background: rgba(0, 0, 0, 0.45);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@keyframes fade-in {
|
|
69
|
+
from { opacity: 0; }
|
|
70
|
+
to { opacity: 1; }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@keyframes scale-in {
|
|
74
|
+
from { opacity: 0; transform: scale(0.95); }
|
|
75
|
+
to { opacity: 1; transform: scale(1); }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
@keyframes slide-in-right {
|
|
79
|
+
from { transform: translateX(100%); }
|
|
80
|
+
to { transform: translateX(0); }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@keyframes slide-in-left {
|
|
84
|
+
from { transform: translateX(-100%); }
|
|
85
|
+
to { transform: translateX(0); }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@keyframes slide-up {
|
|
89
|
+
from { transform: translateY(100%); }
|
|
90
|
+
to { transform: translateY(0); }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@keyframes progress-indeterminate {
|
|
94
|
+
0% { transform: translateX(-100%); }
|
|
95
|
+
100% { transform: translateX(400%); }
|
|
96
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { cn } from '../lib/utils'
|
|
2
|
+
|
|
3
|
+
interface SwitchProps {
|
|
4
|
+
checked: boolean
|
|
5
|
+
onChange: (checked: boolean) => void
|
|
6
|
+
/** Label text shown next to the switch */
|
|
7
|
+
label?: string
|
|
8
|
+
/** Position of the label */
|
|
9
|
+
labelPosition?: 'left' | 'right'
|
|
10
|
+
disabled?: boolean
|
|
11
|
+
className?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function Switch({
|
|
15
|
+
checked,
|
|
16
|
+
onChange,
|
|
17
|
+
label,
|
|
18
|
+
labelPosition = 'right',
|
|
19
|
+
disabled = false,
|
|
20
|
+
className,
|
|
21
|
+
}: SwitchProps) {
|
|
22
|
+
const handleClick = () => {
|
|
23
|
+
if (!disabled) onChange(!checked)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
27
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
28
|
+
e.preventDefault()
|
|
29
|
+
handleClick()
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const switchEl = (
|
|
34
|
+
<button
|
|
35
|
+
onClick={handleClick}
|
|
36
|
+
disabled={disabled}
|
|
37
|
+
className={cn(
|
|
38
|
+
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full',
|
|
39
|
+
'border-2 border-transparent transition-colors duration-200 ease-in-out',
|
|
40
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/20',
|
|
41
|
+
'disabled:opacity-40 disabled:cursor-not-allowed',
|
|
42
|
+
checked ? 'bg-accent' : 'bg-hover',
|
|
43
|
+
)}
|
|
44
|
+
role="switch"
|
|
45
|
+
aria-checked={checked}
|
|
46
|
+
onKeyDown={handleKeyDown}
|
|
47
|
+
>
|
|
48
|
+
<span
|
|
49
|
+
className={cn(
|
|
50
|
+
'pointer-events-none block size-3.5 rounded-full bg-surface shadow-sm',
|
|
51
|
+
'ring-0 transition-transform duration-200 ease-in-out',
|
|
52
|
+
checked ? 'translate-x-[18px]' : 'translate-x-0.5',
|
|
53
|
+
)}
|
|
54
|
+
/>
|
|
55
|
+
</button>
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if (!label) return switchEl
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<label
|
|
62
|
+
className={cn(
|
|
63
|
+
'inline-flex items-center gap-2 select-none cursor-pointer',
|
|
64
|
+
disabled && 'opacity-40 cursor-not-allowed',
|
|
65
|
+
className,
|
|
66
|
+
)}
|
|
67
|
+
>
|
|
68
|
+
{labelPosition === 'left' && (
|
|
69
|
+
<span className="text-sm text-main">{label}</span>
|
|
70
|
+
)}
|
|
71
|
+
{switchEl}
|
|
72
|
+
{labelPosition === 'right' && (
|
|
73
|
+
<span className="text-sm text-main">{label}</span>
|
|
74
|
+
)}
|
|
75
|
+
</label>
|
|
76
|
+
)
|
|
77
|
+
}
|