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:
ByteAtATime 2025-07-05 20:33:12 -07:00
parent ba63d094cf
commit 3ee3787a05
No known key found for this signature in database
23 changed files with 625 additions and 81 deletions

View file

@ -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
View file

@ -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

View file

@ -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)
}

View file

@ -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)}
/>

View 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>

View 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>

View 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}
/>

View 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>

View 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}
/>

View 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,
};

View file

@ -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}
/>

View file

@ -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}
/>

View file

@ -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>

View 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.DescriptionProps = $props();
</script>
<AlertDialogPrimitive.Description
bind:ref
data-slot="alert-dialog-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>

View file

@ -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>

View file

@ -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>

View file

@ -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}
/>

View 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}
/>

View file

@ -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} />

View 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,
};

View file

@ -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}
>

View file

@ -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,
};