mirror of
https://github.com/ByteAtATime/raycast-linux.git
synced 2025-08-31 03:07:23 +00:00
feat(extensions): implement basic heuristic for extension incompatibility
This commit introduces a confirmation dialog for users when installing extensions that may have compatibility issues. It adds a new `ExtensionInstallConfirm` component to display potential violations and allows users to proceed with or cancel the installation. The Rust module has been updated to add these checks and return appropriate results based on user confirmation. Because many Raycast extensions are built with MacOS in mind, they use Mac APIs not available on other platforms.
This commit is contained in:
parent
ba63d094cf
commit
3ee3787a05
23 changed files with 625 additions and 81 deletions
|
@ -38,7 +38,7 @@
|
|||
"@eslint/compat": "^1.2.5",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@internationalized/date": "^3.8.2",
|
||||
"@lucide/svelte": "^0.513.0",
|
||||
"@lucide/svelte": "^0.515.0",
|
||||
"@raycast/api": "^1.99.4",
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.9.0",
|
||||
|
|
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
|
@ -73,8 +73,8 @@ importers:
|
|||
specifier: ^3.8.2
|
||||
version: 3.8.2
|
||||
'@lucide/svelte':
|
||||
specifier: ^0.513.0
|
||||
version: 0.513.0(svelte@5.33.19)
|
||||
specifier: ^0.515.0
|
||||
version: 0.515.0(svelte@5.33.19)
|
||||
'@raycast/api':
|
||||
specifier: ^1.99.4
|
||||
version: 1.99.4(@types/node@24.0.0)
|
||||
|
@ -616,8 +616,8 @@ packages:
|
|||
'@jridgewell/trace-mapping@0.3.25':
|
||||
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
|
||||
|
||||
'@lucide/svelte@0.513.0':
|
||||
resolution: {integrity: sha512-XwBQMQkMlr9qp9yVg+epx5MzhBBrqul8atO00y/ZfhlKRJuQZVmq3ELibApqyBtj9ys0Ai4FH/SZcODTUFYXig==}
|
||||
'@lucide/svelte@0.515.0':
|
||||
resolution: {integrity: sha512-CEAyqcZmNBfYzVgaRmK2RFJP5tnbXxekRyDk0XX/eZQRfsJmkDvmQwXNX8C869BgNeryzmrRyjHhUL6g9ZOHNA==}
|
||||
peerDependencies:
|
||||
svelte: ^5
|
||||
|
||||
|
@ -3187,7 +3187,7 @@ snapshots:
|
|||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
|
||||
'@lucide/svelte@0.513.0(svelte@5.33.19)':
|
||||
'@lucide/svelte@0.515.0(svelte@5.33.19)':
|
||||
dependencies:
|
||||
svelte: 5.33.19
|
||||
|
||||
|
|
Binary file not shown.
|
@ -1,32 +1,53 @@
|
|||
use std::fs;
|
||||
use std::io::{self, Cursor};
|
||||
use std::path::PathBuf;
|
||||
use std::io::{self, Cursor, Read};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use tauri::Manager;
|
||||
use zip::result::ZipError;
|
||||
use zip::ZipArchive;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn install_extension(
|
||||
app: tauri::AppHandle,
|
||||
download_url: String,
|
||||
slug: String,
|
||||
) -> Result<(), String> {
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HeuristicViolation {
|
||||
command_name: String,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase", tag = "status")]
|
||||
pub enum InstallResult {
|
||||
Success,
|
||||
RequiresConfirmation { violations: Vec<HeuristicViolation> },
|
||||
}
|
||||
|
||||
trait IncompatibilityHeuristic {
|
||||
fn check(&self, command_title: &str, file_content: &str) -> Option<HeuristicViolation>;
|
||||
}
|
||||
|
||||
struct AppleScriptHeuristic;
|
||||
impl IncompatibilityHeuristic for AppleScriptHeuristic {
|
||||
fn check(&self, command_title: &str, file_content: &str) -> Option<HeuristicViolation> {
|
||||
if file_content.contains("runAppleScript") {
|
||||
Some(HeuristicViolation {
|
||||
command_name: command_title.to_string(),
|
||||
reason: "Possible usage of AppleScript (runAppleScript)".to_string(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_extension_dir(app: &tauri::AppHandle, slug: &str) -> Result<PathBuf, String> {
|
||||
let data_dir = app
|
||||
.path()
|
||||
.app_local_data_dir()
|
||||
.or_else(|_| Err("Failed to get app local data dir".to_string()))?;
|
||||
.map_err(|_| "Failed to get app local data dir".to_string())?;
|
||||
Ok(data_dir.join("plugins").join(slug))
|
||||
}
|
||||
|
||||
let plugins_dir = data_dir.join("plugins");
|
||||
let extension_dir = plugins_dir.join(&slug);
|
||||
|
||||
if !plugins_dir.exists() {
|
||||
fs::create_dir_all(&plugins_dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
if extension_dir.exists() {
|
||||
fs::remove_dir_all(&extension_dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let response = reqwest::get(&download_url)
|
||||
async fn download_archive(url: &str) -> Result<bytes::Bytes, String> {
|
||||
let response = reqwest::get(url)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to download extension: {}", e))?;
|
||||
|
||||
|
@ -37,38 +58,128 @@ pub async fn install_extension(
|
|||
));
|
||||
}
|
||||
|
||||
let content = response
|
||||
response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response bytes: {}", e))?;
|
||||
.map_err(|e| format!("Failed to read response bytes: {}", e))
|
||||
}
|
||||
|
||||
let mut archive = zip::ZipArchive::new(Cursor::new(content)).map_err(|e| e.to_string())?;
|
||||
|
||||
let prefix_to_strip = {
|
||||
let file_names: Vec<PathBuf> = archive.file_names().map(PathBuf::from).collect();
|
||||
|
||||
if file_names.len() <= 1 {
|
||||
None
|
||||
} else {
|
||||
let first_path = &file_names[0];
|
||||
if let Some(first_component) = first_path.components().next() {
|
||||
if file_names
|
||||
.iter()
|
||||
.all(|path| path.starts_with(first_component))
|
||||
{
|
||||
Some(PathBuf::from(first_component.as_os_str()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
fn find_common_prefix(file_names: &[PathBuf]) -> Option<PathBuf> {
|
||||
if file_names.len() <= 1 {
|
||||
return None;
|
||||
}
|
||||
file_names
|
||||
.get(0)
|
||||
.and_then(|p| p.components().next())
|
||||
.and_then(|first_component| {
|
||||
if file_names
|
||||
.iter()
|
||||
.all(|path| path.starts_with(first_component))
|
||||
{
|
||||
Some(PathBuf::from(first_component.as_os_str()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn get_commands_from_package_json(
|
||||
archive: &mut ZipArchive<Cursor<bytes::Bytes>>,
|
||||
prefix: &Option<PathBuf>,
|
||||
) -> Result<Vec<(String, String)>, String> {
|
||||
let package_json_path = if let Some(ref p) = prefix {
|
||||
p.join("package.json")
|
||||
} else {
|
||||
PathBuf::from("package.json")
|
||||
};
|
||||
|
||||
let mut pkg_file = match archive.by_name(&package_json_path.to_string_lossy()) {
|
||||
Ok(file) => file,
|
||||
Err(ZipError::FileNotFound) => return Ok(vec![]),
|
||||
Err(e) => return Err(e.to_string()),
|
||||
};
|
||||
|
||||
let mut pkg_str = String::new();
|
||||
pkg_file
|
||||
.read_to_string(&mut pkg_str)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let pkg_json: serde_json::Value =
|
||||
serde_json::from_str(&pkg_str).map_err(|_| "Failed to parse package.json".to_string())?;
|
||||
|
||||
let commands = match pkg_json.get("commands").and_then(|c| c.as_array()) {
|
||||
Some(cmds) => cmds,
|
||||
None => return Ok(vec![]),
|
||||
};
|
||||
|
||||
Ok(commands
|
||||
.iter()
|
||||
.filter_map(|command| {
|
||||
let command_name = command.get("name")?.as_str()?;
|
||||
let command_title = command
|
||||
.get("title")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or(command_name)
|
||||
.to_string();
|
||||
|
||||
let src_path = format!("{}.js", command_name);
|
||||
let command_file_path_in_archive = if let Some(ref p) = prefix {
|
||||
p.join(src_path)
|
||||
} else {
|
||||
PathBuf::from(src_path)
|
||||
};
|
||||
|
||||
Some((
|
||||
command_file_path_in_archive.to_string_lossy().into_owned(),
|
||||
command_title,
|
||||
))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn run_heuristic_checks(archive_data: &bytes::Bytes) -> Result<Vec<HeuristicViolation>, String> {
|
||||
let heuristics: Vec<Box<dyn IncompatibilityHeuristic + Send + Sync>> =
|
||||
vec![Box::new(AppleScriptHeuristic)];
|
||||
if heuristics.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let mut archive =
|
||||
ZipArchive::new(Cursor::new(archive_data.clone())).map_err(|e| e.to_string())?;
|
||||
let file_names: Vec<PathBuf> = archive.file_names().map(PathBuf::from).collect();
|
||||
let prefix = find_common_prefix(&file_names);
|
||||
|
||||
let commands_to_check = get_commands_from_package_json(&mut archive, &prefix)?;
|
||||
let mut violations = Vec::new();
|
||||
|
||||
for (path_in_archive, command_title) in commands_to_check {
|
||||
if let Ok(mut command_file) = archive.by_name(&path_in_archive) {
|
||||
let mut content = String::new();
|
||||
if command_file.read_to_string(&mut content).is_ok() {
|
||||
for heuristic in &heuristics {
|
||||
if let Some(violation) = heuristic.check(&command_title, &content) {
|
||||
violations.push(violation);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(violations)
|
||||
}
|
||||
|
||||
fn extract_archive(archive_data: &bytes::Bytes, target_dir: &Path) -> Result<(), String> {
|
||||
if target_dir.exists() {
|
||||
fs::remove_dir_all(target_dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
fs::create_dir_all(target_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let mut archive =
|
||||
ZipArchive::new(Cursor::new(archive_data.clone())).map_err(|e| e.to_string())?;
|
||||
let file_names: Vec<PathBuf> = archive.file_names().map(PathBuf::from).collect();
|
||||
let prefix_to_strip = find_common_prefix(&file_names);
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i).map_err(|e| e.to_string())?;
|
||||
|
||||
let enclosed_path = match file.enclosed_name() {
|
||||
Some(path) => path.to_path_buf(),
|
||||
None => continue,
|
||||
|
@ -87,14 +198,14 @@ pub async fn install_extension(
|
|||
continue;
|
||||
}
|
||||
|
||||
let outpath = extension_dir.join(final_path_part);
|
||||
let outpath = target_dir.join(final_path_part);
|
||||
|
||||
if file.name().ends_with('/') {
|
||||
fs::create_dir_all(&outpath).map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
if let Some(p) = outpath.parent() {
|
||||
if !p.exists() {
|
||||
fs::create_dir_all(&p).map_err(|e| e.to_string())?;
|
||||
fs::create_dir_all(p).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
let mut outfile = fs::File::create(&outpath).map_err(|e| e.to_string())?;
|
||||
|
@ -110,6 +221,27 @@ pub async fn install_extension(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn install_extension(
|
||||
app: tauri::AppHandle,
|
||||
download_url: String,
|
||||
slug: String,
|
||||
force: bool,
|
||||
) -> Result<InstallResult, String> {
|
||||
let extension_dir = get_extension_dir(&app, &slug)?;
|
||||
let content = download_archive(&download_url).await?;
|
||||
|
||||
if !force {
|
||||
let violations = run_heuristic_checks(&content)?;
|
||||
if !violations.is_empty() {
|
||||
return Ok(InstallResult::RequiresConfirmation { violations });
|
||||
}
|
||||
}
|
||||
|
||||
extract_archive(&content, &extension_dir)?;
|
||||
|
||||
Ok(InstallResult::Success)
|
||||
}
|
||||
|
|
|
@ -13,18 +13,26 @@
|
|||
import type { VListHandle } from 'virtua/svelte';
|
||||
import HeaderInput from './HeaderInput.svelte';
|
||||
import { viewManager } from '$lib/viewManager.svelte';
|
||||
import ExtensionInstallConfirm from './extensions/ExtensionInstallConfirm.svelte';
|
||||
|
||||
type Props = {
|
||||
onBack: () => void;
|
||||
onInstall: () => void;
|
||||
};
|
||||
|
||||
type Violation = {
|
||||
commandName: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
let { onBack, onInstall }: Props = $props();
|
||||
|
||||
let selectedExtension = $state<Datum | null>(null);
|
||||
let expandedImageUrl = $state<string | null>(null);
|
||||
let isInstalling = $state(false);
|
||||
let vlistInstance = $state<VListHandle | null>(null);
|
||||
let showConfirmationDialog = $state(false);
|
||||
let confirmationViolations = $state<Violation[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
const ext = viewManager.extensionToSelect;
|
||||
|
@ -63,14 +71,42 @@
|
|||
async function handleInstall() {
|
||||
if (!selectedExtension || isInstalling) return;
|
||||
isInstalling = true;
|
||||
try {
|
||||
const result = await invoke<{
|
||||
status: 'success' | 'requiresConfirmation';
|
||||
violations?: Violation[];
|
||||
}>('install_extension', {
|
||||
downloadUrl: selectedExtension.download_url,
|
||||
slug: selectedExtension.name,
|
||||
force: false
|
||||
});
|
||||
|
||||
if (result.status === 'success') {
|
||||
onInstall();
|
||||
} else if (result.status === 'requiresConfirmation' && result.violations) {
|
||||
confirmationViolations = result.violations;
|
||||
showConfirmationDialog = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Installation failed', e);
|
||||
} finally {
|
||||
isInstalling = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleForceInstall() {
|
||||
showConfirmationDialog = false;
|
||||
if (!selectedExtension) return;
|
||||
isInstalling = true;
|
||||
try {
|
||||
await invoke('install_extension', {
|
||||
downloadUrl: selectedExtension.download_url,
|
||||
slug: selectedExtension.name
|
||||
slug: selectedExtension.name,
|
||||
force: true
|
||||
});
|
||||
onInstall();
|
||||
} catch (e) {
|
||||
console.error('Installation failed', e);
|
||||
console.error('Forced installation failed', e);
|
||||
} finally {
|
||||
isInstalling = false;
|
||||
}
|
||||
|
@ -121,3 +157,10 @@
|
|||
{#if expandedImageUrl}
|
||||
<ImageLightbox imageUrl={expandedImageUrl} onClose={() => (expandedImageUrl = null)} />
|
||||
{/if}
|
||||
|
||||
<ExtensionInstallConfirm
|
||||
bind:open={showConfirmationDialog}
|
||||
violations={confirmationViolations}
|
||||
onconfirm={handleForceInstall}
|
||||
oncancel={() => (showConfirmationDialog = false)}
|
||||
/>
|
||||
|
|
64
src/lib/components/extensions/ExtensionInstallConfirm.svelte
Normal file
64
src/lib/components/extensions/ExtensionInstallConfirm.svelte
Normal file
|
@ -0,0 +1,64 @@
|
|||
<script lang="ts">
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Accordion from '$lib/components/ui/accordion';
|
||||
import { TriangleAlert } from '@lucide/svelte';
|
||||
|
||||
type Violation = {
|
||||
commandName: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
violations: Violation[];
|
||||
open: boolean;
|
||||
onconfirm: () => {};
|
||||
oncancel: () => {};
|
||||
};
|
||||
|
||||
let { violations, open = $bindable(), onconfirm, oncancel }: Props = $props();
|
||||
|
||||
const isTruncated = $derived(violations.length > 3);
|
||||
const truncatedViolations = $derived(violations.slice(0, 3));
|
||||
</script>
|
||||
|
||||
<AlertDialog.Root bind:open onOpenChange={(isOpen) => !isOpen && oncancel()}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<div class="flex flex-col items-center gap-2 text-center">
|
||||
<TriangleAlert class="size-12 text-yellow-400" />
|
||||
<AlertDialog.Title>Potential Incompatibility Detected</AlertDialog.Title>
|
||||
</div>
|
||||
<AlertDialog.Description class="text-center">
|
||||
This extension may not work as expected on your system. We recommend proceeding with
|
||||
caution.
|
||||
<Accordion.Root class="w-full pt-4" type="multiple">
|
||||
<Accordion.Item value="details">
|
||||
<Accordion.Trigger>Technical Details</Accordion.Trigger>
|
||||
<Accordion.Content>
|
||||
<ul class="list-disc space-y-2 pl-5 text-left text-xs">
|
||||
{#each truncatedViolations as violation}
|
||||
<li>
|
||||
<strong>{violation.commandName}:</strong>
|
||||
{violation.reason}
|
||||
</li>
|
||||
{/each}
|
||||
{#if isTruncated}
|
||||
<li>... {violations.length - 3} more warnings</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion.Root>
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel onclick={() => oncancel()}>Cancel</AlertDialog.Cancel>
|
||||
<AlertDialog.Action>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} onclick={() => onconfirm()}>Install anyway</Button>
|
||||
{/snippet}
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
22
src/lib/components/ui/accordion/accordion-content.svelte
Normal file
22
src/lib/components/ui/accordion/accordion-content.svelte
Normal file
|
@ -0,0 +1,22 @@
|
|||
<script lang="ts">
|
||||
import { Accordion as AccordionPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithoutChild<AccordionPrimitive.ContentProps> = $props();
|
||||
</script>
|
||||
|
||||
<AccordionPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="accordion-content"
|
||||
class="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...restProps}
|
||||
>
|
||||
<div class={cn("pb-4 pt-0", className)}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</AccordionPrimitive.Content>
|
17
src/lib/components/ui/accordion/accordion-item.svelte
Normal file
17
src/lib/components/ui/accordion/accordion-item.svelte
Normal file
|
@ -0,0 +1,17 @@
|
|||
<script lang="ts">
|
||||
import { Accordion as AccordionPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AccordionPrimitive.ItemProps = $props();
|
||||
</script>
|
||||
|
||||
<AccordionPrimitive.Item
|
||||
bind:ref
|
||||
data-slot="accordion-item"
|
||||
class={cn("border-b last:border-b-0", className)}
|
||||
{...restProps}
|
||||
/>
|
32
src/lib/components/ui/accordion/accordion-trigger.svelte
Normal file
32
src/lib/components/ui/accordion/accordion-trigger.svelte
Normal file
|
@ -0,0 +1,32 @@
|
|||
<script lang="ts">
|
||||
import { Accordion as AccordionPrimitive } from "bits-ui";
|
||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
level = 3,
|
||||
children,
|
||||
...restProps
|
||||
}: WithoutChild<AccordionPrimitive.TriggerProps> & {
|
||||
level?: AccordionPrimitive.HeaderProps["level"];
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<AccordionPrimitive.Header {level} class="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
bind:ref
|
||||
class={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium outline-none transition-all hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<ChevronDownIcon
|
||||
class="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200"
|
||||
/>
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
16
src/lib/components/ui/accordion/accordion.svelte
Normal file
16
src/lib/components/ui/accordion/accordion.svelte
Normal file
|
@ -0,0 +1,16 @@
|
|||
<script lang="ts">
|
||||
import { Accordion as AccordionPrimitive } from "bits-ui";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
...restProps
|
||||
}: AccordionPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<AccordionPrimitive.Root
|
||||
bind:ref
|
||||
bind:value={value as never}
|
||||
data-slot="accordion"
|
||||
{...restProps}
|
||||
/>
|
16
src/lib/components/ui/accordion/index.ts
Normal file
16
src/lib/components/ui/accordion/index.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import Root from "./accordion.svelte";
|
||||
import Content from "./accordion-content.svelte";
|
||||
import Item from "./accordion-item.svelte";
|
||||
import Trigger from "./accordion-trigger.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Item,
|
||||
Trigger,
|
||||
//
|
||||
Root as Accordion,
|
||||
Content as AccordionContent,
|
||||
Item as AccordionItem,
|
||||
Trigger as AccordionTrigger,
|
||||
};
|
|
@ -0,0 +1,18 @@
|
|||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.ActionProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Action
|
||||
bind:ref
|
||||
data-slot="alert-dialog-action"
|
||||
class={cn(buttonVariants(), className)}
|
||||
{...restProps}
|
||||
/>
|
|
@ -0,0 +1,18 @@
|
|||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.CancelProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Cancel
|
||||
bind:ref
|
||||
data-slot="alert-dialog-cancel"
|
||||
class={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...restProps}
|
||||
/>
|
|
@ -0,0 +1,27 @@
|
|||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
import AlertDialogOverlay from "./alert-dialog-overlay.svelte";
|
||||
import { cn, type WithoutChild, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
portalProps,
|
||||
...restProps
|
||||
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
|
||||
portalProps?: WithoutChildrenOrChild<AlertDialogPrimitive.PortalProps>;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Portal {...portalProps}>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="alert-dialog-content"
|
||||
class={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
</AlertDialogPrimitive.Portal>
|
|
@ -0,0 +1,17 @@
|
|||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.DescriptionProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Description
|
||||
bind:ref
|
||||
data-slot="alert-dialog-description"
|
||||
class={cn("text-muted-foreground text-sm", className)}
|
||||
{...restProps}
|
||||
/>
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="alert-dialog-footer"
|
||||
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="alert-dialog-header"
|
||||
class={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.OverlayProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Overlay
|
||||
bind:ref
|
||||
data-slot="alert-dialog-overlay"
|
||||
class={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
17
src/lib/components/ui/alert-dialog/alert-dialog-title.svelte
Normal file
17
src/lib/components/ui/alert-dialog/alert-dialog-title.svelte
Normal file
|
@ -0,0 +1,17 @@
|
|||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.TitleProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Title
|
||||
bind:ref
|
||||
data-slot="alert-dialog-title"
|
||||
class={cn("text-lg font-semibold", className)}
|
||||
{...restProps}
|
||||
/>
|
|
@ -0,0 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: AlertDialogPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Trigger bind:ref data-slot="alert-dialog-trigger" {...restProps} />
|
39
src/lib/components/ui/alert-dialog/index.ts
Normal file
39
src/lib/components/ui/alert-dialog/index.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
import Trigger from "./alert-dialog-trigger.svelte";
|
||||
import Title from "./alert-dialog-title.svelte";
|
||||
import Action from "./alert-dialog-action.svelte";
|
||||
import Cancel from "./alert-dialog-cancel.svelte";
|
||||
import Footer from "./alert-dialog-footer.svelte";
|
||||
import Header from "./alert-dialog-header.svelte";
|
||||
import Overlay from "./alert-dialog-overlay.svelte";
|
||||
import Content from "./alert-dialog-content.svelte";
|
||||
import Description from "./alert-dialog-description.svelte";
|
||||
|
||||
const Root = AlertDialogPrimitive.Root;
|
||||
const Portal = AlertDialogPrimitive.Portal;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Title,
|
||||
Action,
|
||||
Cancel,
|
||||
Portal,
|
||||
Footer,
|
||||
Header,
|
||||
Trigger,
|
||||
Overlay,
|
||||
Content,
|
||||
Description,
|
||||
//
|
||||
Root as AlertDialog,
|
||||
Title as AlertDialogTitle,
|
||||
Action as AlertDialogAction,
|
||||
Cancel as AlertDialogCancel,
|
||||
Portal as AlertDialogPortal,
|
||||
Footer as AlertDialogFooter,
|
||||
Header as AlertDialogHeader,
|
||||
Trigger as AlertDialogTrigger,
|
||||
Overlay as AlertDialogOverlay,
|
||||
Content as AlertDialogContent,
|
||||
Description as AlertDialogDescription,
|
||||
};
|
|
@ -1,37 +1,36 @@
|
|||
<script lang="ts" module>
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
|
||||
import { type VariantProps, tv } from 'tailwind-variants';
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
|
||||
export const buttonVariants = tv({
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
||||
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white',
|
||||
"bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white",
|
||||
outline:
|
||||
'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline'
|
||||
"bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
|
||||
action: 'h-7 gap-1.5 rounded-md pl-2 pr-1 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9'
|
||||
}
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
});
|
||||
|
||||
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
|
||||
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
|
||||
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
||||
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
|
||||
|
||||
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||
WithElementRef<HTMLAnchorAttributes> & {
|
||||
|
@ -43,11 +42,11 @@
|
|||
<script lang="ts">
|
||||
let {
|
||||
class: className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
variant = "default",
|
||||
size = "default",
|
||||
ref = $bindable(null),
|
||||
href = undefined,
|
||||
type = 'button',
|
||||
type = "button",
|
||||
disabled,
|
||||
children,
|
||||
...restProps
|
||||
|
@ -61,7 +60,7 @@
|
|||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
href={disabled ? undefined : href}
|
||||
aria-disabled={disabled}
|
||||
role={disabled ? 'link' : undefined}
|
||||
role={disabled ? "link" : undefined}
|
||||
tabindex={disabled ? -1 : undefined}
|
||||
{...restProps}
|
||||
>
|
||||
|
|
|
@ -2,8 +2,8 @@ import Root, {
|
|||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
buttonVariants
|
||||
} from './button.svelte';
|
||||
buttonVariants,
|
||||
} from "./button.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
|
@ -13,5 +13,5 @@ export {
|
|||
buttonVariants,
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant
|
||||
type ButtonVariant,
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue