feat: unraid ui component library (#976)

This commit is contained in:
Michael Datelle
2024-12-20 14:08:34 -05:00
committed by GitHub
parent e2a1f27b22
commit 91de6e6c1e
100 changed files with 14212 additions and 2 deletions

6
.gitignore vendored
View File

@@ -24,6 +24,7 @@ build/Release
# Dependency directories
node_modules/
jspm_packages/
unraid-ui/node_modules/
# TypeScript v1 declaration files
typings/
@@ -64,6 +65,7 @@ test/__temp__/*
# Built files
dist
unraid-ui/storybook-static
# Typescript
typescript
@@ -74,7 +76,7 @@ typescript
# Github actions
RELEASE_NOTES.md
# Docker Deploy Folder
# Docker Deploy Folder
deploy/*
!deploy/.gitkeep
@@ -89,4 +91,4 @@ deploy/*
.env*
!.env.example
fb_keepalive
fb_keepalive

2
unraid-ui/.npmrc Normal file
View File

@@ -0,0 +1,2 @@
shamefully-hoist=true
strict-peer-dependencies=false

View File

@@ -0,0 +1,46 @@
import type { StorybookConfig } from "@storybook/vue3-vite";
import { resolve } from "path";
import { mergeConfig } from "vite";
const config: StorybookConfig = {
stories: ["../stories/**/*.stories.@(js|mjs|ts)"],
addons: [
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/addon-interactions",
"@storybook/addon-controls",
{
name: "@storybook/addon-postcss",
options: {
postcssLoaderOptions: {
implementation: require("postcss"),
},
},
},
],
framework: {
name: "@storybook/vue3-vite",
options: {
docgen: "vue-component-meta",
},
},
docs: {
autodocs: "tag",
},
viteFinal: async (config) => {
return mergeConfig(config, {
resolve: {
alias: {
"@": resolve(__dirname, "../src"),
"@/components": resolve(__dirname, "../src/components"),
"@/lib": resolve(__dirname, "../src/lib"),
},
},
css: {
postcss: "./postcss.config.js",
},
});
},
};
export default config;

View File

@@ -0,0 +1,16 @@
import type { Preview } from "@storybook/vue3";
import "../src/styles/globals.css";
const preview: Preview = {
parameters: {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
},
};
export default preview;

View File

@@ -0,0 +1,10 @@
import baseConfig from "../tailwind.config";
export default {
...baseConfig,
content: [
"../src/**/*.{vue,js,ts,jsx,tsx}",
"../stories/**/*.{js,ts,jsx,tsx,mdx}",
"../.storybook/**/*.{js,ts,jsx,tsx,mdx}",
],
};

View File

@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"paths": {
"@/*": ["../src/*"]
}
},
"include": ["../stories/**/*", "../src/**/*"]
}

122
unraid-ui/README.md Normal file
View File

@@ -0,0 +1,122 @@
# Unraid UI
A Vue 3 component library providing a set of reusable, accessible UI components for Unraid development.
## Features
- ⚡️ Built with Vue 3 and TypeScript
- 🎭 Storybook documentation
- ✅ Tested components
- 🎪 Built on top of TailwindCSS and Shadcn/UI
## Installation
Make sure you have the peer dependencies installed:
```bash
npm install vue@^3.3.0 tailwindcss@^3.0.0
```
## Setup
### 1. Add CSS
Import the component library styles in your main entry file:
```typescript
import "@unraid/ui/style.css";
```
### 2. Configure TailwindCSS
Add the following to your `tailwind.config.js`:
```javascript
module.exports = {
content: [
// ... your content paths
"./node_modules/@unraid/ui/**/*.{js,vue,ts}",
],
theme: {
extend: {},
},
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
};
```
## Usage
```vue
<script setup lang="ts">
import { Button } from "@unraid/ui";
</script>
<template>
<Button variant="primary"> Click me </Button>
</template>
```
## Development
### Local Development
Install dependencies:
```bash
npm install
```
Start Storybook development server:
```bash
npm run storybook
```
This will start Storybook at [http://localhost:6006](http://localhost:6006)
### Building
```bash
npm run build
```
### Testing
Run tests:
```bash
npm run test
```
Run tests with UI:
```bash
npm run test:ui
```
Generate coverage report:
```bash
npm run coverage
```
### Type Checking
```bash
npm run typecheck
```
## Scripts
- `dev`: Start development server
- `build`: Build for production
- `preview`: Preview production build
- `test`: Run tests
- `test:ui`: Run tests with UI
- `coverage`: Generate test coverage
- `clean`: Remove build artifacts
- `typecheck`: Run type checking
- `storybook`: Start Storybook development server
- `build-storybook`: Build Storybook for production
## License

19
unraid-ui/components.json Normal file
View File

@@ -0,0 +1,19 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "default",
"typescript": true,
"tsConfigPath": "./tsconfig.json",
"tailwind": {
"config": "./tailwind.config.js",
"css": "./src/styles/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"framework": "vite",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"types": "@/types"
}
}

11111
unraid-ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

74
unraid-ui/package.json Normal file
View File

@@ -0,0 +1,74 @@
{
"name": "@unraid/ui",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"sideEffects": false,
"files": [
"dist"
],
"scripts": {
"dev": "vite",
"build": "vite build && vue-tsc --emitDeclarationOnly",
"preview": "vite preview",
"test": "vitest",
"test:ui": "vitest --ui",
"coverage": "vitest run --coverage",
"clean": "rm -rf dist",
"prebuild": "npm run clean",
"typecheck": "vue-tsc --noEmit",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"peerDependencies": {
"tailwindcss": "^3.0.0",
"vue": "^3.3.0"
},
"dependencies": {
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.2.0",
"@vueuse/core": "^10.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-vue-next": "^0.468.0",
"radix-vue": "^1.9.11",
"shadcn-vue": "^0.11.3",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@storybook/addon-controls": "^8.4.7",
"@storybook/addon-postcss": "^2.0.0",
"@storybook/vue3-vite": "^8.4.7",
"@tailwindcss/typography": "^0.5.15",
"@testing-library/vue": "^8.0.0",
"@types/jsdom": "^21.1.7",
"@types/node": "^20.0.0",
"@types/testing-library__vue": "^5.0.0",
"@vitejs/plugin-vue": "^5.0.0",
"@vitest/coverage-v8": "^1.0.0",
"@vitest/ui": "^1.0.0",
"@vue/test-utils": "^2.4.0",
"@vue/tsconfig": "^0.5.0",
"autoprefixer": "^10.4.20",
"happy-dom": "^12.0.0",
"postcss": "^8.4.49",
"tailwindcss": "^3.0.0",
"typescript": "^5.0.0",
"vite": "^5.0.0",
"vite-plugin-dts": "^3.0.0",
"vitest": "^1.0.0",
"vue": "^3.3.0",
"vue-tsc": "^1.8.0"
},
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./style.css": "./dist/style.css"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,127 @@
<script setup lang="ts">
import { computed } from "vue";
import type { UiBadgeProps } from "@/types/badge";
const props = withDefaults(defineProps<UiBadgeProps>(), {
color: "gray",
icon: undefined,
iconRight: undefined,
iconStyles: "",
size: "16px",
});
const computedStyleClasses = computed(() => {
let colorClasses = "";
let textSize = "";
let iconSize = "";
switch (props.color) {
case "red":
colorClasses =
"bg-unraid-red text-white group-hover:bg-orange-dark group-focus:bg-orange-dark";
break;
case "yellow":
colorClasses =
"bg-yellow-100 text-black group-hover:bg-yellow-200 group-focus:bg-yellow-200";
break;
case "green":
colorClasses =
"bg-green-200 text-green-800 group-hover:bg-green-300 group-focus:bg-green-300";
break;
case "blue":
colorClasses =
"bg-blue-100 text-blue-800 group-hover:bg-blue-200 group-focus:bg-blue-200";
break;
case "indigo":
colorClasses =
"bg-indigo-100 text-indigo-800 group-hover:bg-indigo-200 group-focus:bg-indigo-200";
break;
case "purple":
colorClasses =
"bg-purple-100 text-purple-800 group-hover:bg-purple-200 group-focus:bg-purple-200";
break;
case "pink":
colorClasses =
"bg-pink-100 text-pink-800 group-hover:bg-pink-200 group-focus:bg-pink-200";
break;
case "orange":
colorClasses =
"bg-orange text-white group-hover:bg-orange-dark group-focus:bg-orange-dark";
break;
case "black":
colorClasses =
"bg-black text-white group-hover:bg-gray-800 group-focus:bg-gray-800";
break;
case "white":
colorClasses =
"bg-white text-black group-hover:bg-gray-100 group-focus:bg-gray-100";
break;
case "transparent":
colorClasses =
"bg-transparent text-black group-hover:bg-gray-100 group-focus:bg-gray-100";
break;
case "current":
colorClasses =
"bg-current text-black group-hover:bg-gray-100 group-focus:bg-gray-100";
break;
case "gray":
colorClasses =
"bg-gray-200 text-gray-800 group-hover:bg-gray-300 group-focus:bg-gray-300";
break;
case "custom":
colorClasses = "";
break;
}
switch (props.size) {
case "12px":
textSize = "text-12px px-8px py-4px gap-4px";
iconSize = "w-12px";
break;
case "14px":
textSize = "text-14px px-8px py-4px gap-8px";
iconSize = "w-14px";
break;
case "16px":
textSize = "text-16px px-12px py-8px gap-8px";
iconSize = "w-16px";
break;
case "18px":
textSize = "text-18px px-12px py-8px gap-8px";
iconSize = "w-18px";
break;
case "20px":
textSize = "text-20px px-16px py-12px gap-8px";
iconSize = "w-20px";
break;
case "24px":
textSize = "text-24px px-16px py-12px gap-8px";
iconSize = "w-24px";
break;
}
return {
badge: `${textSize} ${colorClasses}`,
icon: `${iconSize} ${props.iconStyles}`,
};
});
</script>
<template>
<span
class="inline-flex items-center rounded-full font-semibold leading-none transition-all duration-200 ease-in-out"
:class="[computedStyleClasses.badge]"
>
<component
:is="icon"
v-if="icon"
class="flex-shrink-0"
:class="computedStyleClasses.icon"
/>
<slot />
<component
:is="iconRight"
v-if="iconRight"
class="flex-shrink-0"
:class="computedStyleClasses.icon"
/>
</span>
</template>

View File

@@ -0,0 +1,3 @@
import Badge from "./Badge.vue";
export { Badge };

View File

@@ -0,0 +1,54 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/vue";
import Button from "./Button.vue";
describe("Button", () => {
it("renders correctly with default props", () => {
render(Button, {
slots: {
default: "Click me",
},
});
expect(screen.getByText("Click me")).toBeDefined();
});
it("renders with different variants", () => {
const { rerender } = render(Button, {
props: { variant: "destructive" },
slots: { default: "Delete" },
});
rerender({
props: { variant: "outline" },
slots: { default: "Delete" },
});
});
it("renders with different sizes", () => {
const { rerender } = render(Button, {
props: { size: "sm" },
slots: { default: "Small Button" },
});
rerender({
props: { size: "lg" },
slots: { default: "Large Button" },
});
});
it("accepts and applies additional classes", () => {
render(Button, {
props: {
class: "custom-class",
},
slots: {
default: "Custom Button",
},
});
const button = screen.getByRole("button");
expect(button.classList.contains("custom-class")).toBe(true);
});
});

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import { computed } from "vue";
import { ButtonVariants } from "./button.variants";
import { cn } from "@/lib/utils";
interface Props {
variant?:
| "primary"
| "destructive"
| "outline"
| "secondary"
| "ghost"
| "link";
size?: "sm" | "md" | "lg" | "icon";
class?: string;
}
const props = withDefaults(defineProps<Props>(), {
variant: "primary",
size: "md",
});
const buttonClass = computed(() => {
return cn(
ButtonVariants({ variant: props.variant, size: props.size }),
props.class
);
});
</script>
<template>
<button :class="buttonClass">
<slot />
</button>
</template>

View File

@@ -0,0 +1,30 @@
import { cva } from "class-variance-authority";
export const ButtonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
sm: "h-9 rounded-md px-3",
md: "h-10 px-4 py-2",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "primary",
size: "md",
},
}
);

View File

@@ -0,0 +1,2 @@
export { default as Button } from "./Button.vue";
export { ButtonVariants } from "./button.variants";

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import {
DropdownMenuRoot,
type DropdownMenuRootEmits,
type DropdownMenuRootProps,
useForwardPropsEmits,
} from "radix-vue";
const props = defineProps<DropdownMenuRootProps>();
const emits = defineEmits<DropdownMenuRootEmits>();
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<DropdownMenuRoot v-bind="forwarded">
<slot />
</DropdownMenuRoot>
</template>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import {
DropdownMenuCheckboxItem,
type DropdownMenuCheckboxItemEmits,
type DropdownMenuCheckboxItemProps,
DropdownMenuItemIndicator,
useForwardPropsEmits,
} from "radix-vue";
import { Check } from "lucide-vue-next";
import { cn } from "@/lib/utils";
const props = defineProps<
DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<DropdownMenuCheckboxItemEmits>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DropdownMenuCheckboxItem
v-bind="forwarded"
:class="
cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
props.class
)
"
>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuItemIndicator>
<Check class="w-4 h-4" />
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuCheckboxItem>
</template>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import {
DropdownMenuContent,
type DropdownMenuContentEmits,
type DropdownMenuContentProps,
DropdownMenuPortal,
useForwardPropsEmits,
} from "radix-vue";
import { cn } from "@/lib/utils";
const props = withDefaults(
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes["class"] }>(),
{
sideOffset: 4,
class: undefined,
}
);
const emits = defineEmits<DropdownMenuContentEmits>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DropdownMenuPortal>
<DropdownMenuContent
v-bind="forwarded"
:class="
cn(
'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
props.class
)
"
>
<slot />
</DropdownMenuContent>
</DropdownMenuPortal>
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import { DropdownMenuGroup, type DropdownMenuGroupProps } from "radix-vue";
const props = defineProps<DropdownMenuGroupProps>();
</script>
<template>
<DropdownMenuGroup v-bind="props">
<slot />
</DropdownMenuGroup>
</template>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import {
DropdownMenuItem,
type DropdownMenuItemProps,
useForwardProps,
} from "radix-vue";
import { cn } from "@/lib/utils";
const props = defineProps<
DropdownMenuItemProps & { class?: HTMLAttributes["class"]; inset?: boolean }
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<DropdownMenuItem
v-bind="forwardedProps"
:class="
cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
props.class
)
"
>
<slot />
</DropdownMenuItem>
</template>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import {
DropdownMenuLabel,
type DropdownMenuLabelProps,
useForwardProps,
} from "radix-vue";
import { cn } from "@/lib/utils";
const props = defineProps<
DropdownMenuLabelProps & { class?: HTMLAttributes["class"]; inset?: boolean }
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<DropdownMenuLabel
v-bind="forwardedProps"
:class="
cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', props.class)
"
>
<slot />
</DropdownMenuLabel>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import {
DropdownMenuRadioGroup,
type DropdownMenuRadioGroupEmits,
type DropdownMenuRadioGroupProps,
useForwardPropsEmits,
} from "radix-vue";
const props = defineProps<DropdownMenuRadioGroupProps>();
const emits = defineEmits<DropdownMenuRadioGroupEmits>();
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<DropdownMenuRadioGroup v-bind="forwarded">
<slot />
</DropdownMenuRadioGroup>
</template>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import {
DropdownMenuItemIndicator,
DropdownMenuRadioItem,
type DropdownMenuRadioItemEmits,
type DropdownMenuRadioItemProps,
useForwardPropsEmits,
} from "radix-vue";
import { Circle } from "lucide-vue-next";
import { cn } from "@/lib/utils";
const props = defineProps<
DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<DropdownMenuRadioItemEmits>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DropdownMenuRadioItem
v-bind="forwarded"
:class="
cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
props.class
)
"
>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuItemIndicator>
<Circle class="h-2 w-2 fill-current" />
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuRadioItem>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import {
DropdownMenuSeparator,
type DropdownMenuSeparatorProps,
} from "radix-vue";
import { cn } from "@/lib/utils";
const props = defineProps<
DropdownMenuSeparatorProps & {
class?: HTMLAttributes["class"];
}
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<DropdownMenuSeparator
v-bind="delegatedProps"
:class="cn('-mx-1 my-1 h-px bg-muted', props.class)"
/>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<span :class="cn('ml-auto text-xs tracking-widest opacity-60', props.class)">
<slot />
</span>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import {
DropdownMenuSub,
type DropdownMenuSubEmits,
type DropdownMenuSubProps,
useForwardPropsEmits,
} from "radix-vue";
const props = defineProps<DropdownMenuSubProps>();
const emits = defineEmits<DropdownMenuSubEmits>();
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<DropdownMenuSub v-bind="forwarded">
<slot />
</DropdownMenuSub>
</template>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import {
DropdownMenuSubContent,
type DropdownMenuSubContentEmits,
type DropdownMenuSubContentProps,
useForwardPropsEmits,
} from "radix-vue";
import { cn } from "@/lib/utils";
const props = defineProps<
DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<DropdownMenuSubContentEmits>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DropdownMenuSubContent
v-bind="forwarded"
:class="
cn(
'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
props.class
)
"
>
<slot />
</DropdownMenuSubContent>
</template>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import {
DropdownMenuSubTrigger,
type DropdownMenuSubTriggerProps,
useForwardProps,
} from "radix-vue";
import { ChevronRight } from "lucide-vue-next";
import { cn } from "@/lib/utils";
const props = defineProps<
DropdownMenuSubTriggerProps & { class?: HTMLAttributes["class"] }
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<DropdownMenuSubTrigger
v-bind="forwardedProps"
:class="
cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
props.class
)
"
>
<slot />
<ChevronRight class="ml-auto h-4 w-4" />
</DropdownMenuSubTrigger>
</template>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import { DropdownMenuTrigger, type DropdownMenuTriggerProps, useForwardProps } from 'radix-vue'
const props = defineProps<DropdownMenuTriggerProps>()
const forwardedProps = useForwardProps(props)
</script>
<template>
<DropdownMenuTrigger class="outline-none" v-bind="forwardedProps">
<slot />
</DropdownMenuTrigger>
</template>

View File

@@ -0,0 +1,16 @@
export { DropdownMenuPortal } from 'radix-vue'
export { default as DropdownMenu } from './DropdownMenu.vue'
export { default as DropdownMenuTrigger } from './DropdownMenuTrigger.vue'
export { default as DropdownMenuContent } from './DropdownMenuContent.vue'
export { default as DropdownMenuGroup } from './DropdownMenuGroup.vue'
export { default as DropdownMenuRadioGroup } from './DropdownMenuRadioGroup.vue'
export { default as DropdownMenuItem } from './DropdownMenuItem.vue'
export { default as DropdownMenuCheckboxItem } from './DropdownMenuCheckboxItem.vue'
export { default as DropdownMenuRadioItem } from './DropdownMenuRadioItem.vue'
export { default as DropdownMenuShortcut } from './DropdownMenuShortcut.vue'
export { default as DropdownMenuSeparator } from './DropdownMenuSeparator.vue'
export { default as DropdownMenuLabel } from './DropdownMenuLabel.vue'
export { default as DropdownMenuSub } from './DropdownMenuSub.vue'
export { default as DropdownMenuSubTrigger } from './DropdownMenuSubTrigger.vue'
export { default as DropdownMenuSubContent } from './DropdownMenuSubContent.vue'

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
const props = withDefaults(defineProps<{ class?: string }>(), { class: "" });
</script>
<template>
<div
:class="cn('h-5 animate-pulse bg-gray-300 w-full', props.class)"
role="progressbar"
aria-label="Loading"
/>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import { ShieldExclamationIcon } from "@heroicons/vue/24/solid";
import LoadingSpinner from "./Spinner.vue";
import { cn } from "@/lib/utils";
/**
* A default container for displaying loading and error states.
*
* By default, this component will expand to full height and display contents
* in the center of the container.
*
* Any slot/child will only render when a loading/error state isn't displayed.
*
* Exposes a 'retry' event (user-triggered during error state).
*
* @example
* <LoadingError @retry="retryFunction" :loading="loading" :error="error" />
*
* <LoadingError :loading="loading" :error="error">
* <p>Only displayed when both loading and error are false.</p>
* </LoadingError>
*/
const props = withDefaults(
defineProps<{
class?: string;
/** hasdfsa */
loading: boolean;
error: Error | null | undefined;
}>(),
{ class: "" }
);
defineEmits(["retry"]);
</script>
<template>
<div
:class="
cn('h-full flex flex-col items-center justify-center gap-3', props.class)
"
>
<!-- Loading State -->
<div v-if="loading" class="contents">
<LoadingSpinner />
<p>Loading Notifications...</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="space-y-3">
<div class="flex justify-center">
<ShieldExclamationIcon class="h-10 text-unraid-red" />
</div>
<div>
<h3 class="font-bold">{{ `Error` }}</h3>
<p>{{ error.message }}</p>
</div>
<Button type="button" class="w-full" @click="$emit('retry')"
>Try Again</Button
>
</div>
<!-- Default state -->
<slot v-else />
</div>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
const props = withDefaults(defineProps<{ class?: string }>(), { class: "" });
</script>
<template>
<!-- adapted from https://tw-elements.com/docs/standard/components/spinners/ -->
<div
:class="
cn(
'inline-block h-8 w-8 animate-spin rounded-full border-2 border-solid border-current border-e-transparent align-[-0.125em] text-primary motion-reduce:animate-[spin_1.5s_linear_infinite]',
props.class
)
"
role="status"
>
<span class="sr-only">Loading...</span>
</div>
</template>

View File

@@ -0,0 +1,5 @@
import Bar from "./Bar.vue";
import Error from "./Error.vue";
import Spinner from "./Spinner.vue";
export { Bar, Error, Spinner };

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import {
ScrollAreaCorner,
ScrollAreaRoot,
type ScrollAreaRootProps,
ScrollAreaViewport,
} from "radix-vue";
import ScrollBar from "./ScrollBar.vue";
import { cn } from "@/lib/utils";
const props = defineProps<
ScrollAreaRootProps & { class?: HTMLAttributes["class"] }
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<ScrollAreaRoot
v-bind="delegatedProps"
:class="cn('relative overflow-hidden', props.class)"
>
<ScrollAreaViewport class="h-full w-full rounded-[inherit]">
<slot />
</ScrollAreaViewport>
<ScrollBar />
<ScrollAreaCorner />
</ScrollAreaRoot>
</template>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import {
ScrollAreaScrollbar,
type ScrollAreaScrollbarProps,
ScrollAreaThumb,
} from "radix-vue";
import { cn } from "@/lib/utils";
const props = withDefaults(
defineProps<ScrollAreaScrollbarProps & { class?: HTMLAttributes["class"] }>(),
{
orientation: "vertical",
class: undefined,
}
);
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<ScrollAreaScrollbar
v-bind="delegatedProps"
:class="
cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent p-px',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent p-px',
props.class
)
"
>
<ScrollAreaThumb class="relative flex-1 rounded-full bg-border" />
</ScrollAreaScrollbar>
</template>

View File

@@ -0,0 +1,2 @@
export { default as ScrollArea } from './ScrollArea.vue'
export { default as ScrollBar } from './ScrollBar.vue'

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import { ref, onUnmounted } from "vue";
import {
DialogRoot,
useForwardPropsEmits,
type DialogRootEmits,
type DialogRootProps,
} from "radix-vue";
const MOBILE_VIEWPORT =
"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" as const;
const props = defineProps<DialogRootProps & { class?: string }>();
const emits = defineEmits<DialogRootEmits>();
const getViewport = (): string => {
return (
document.querySelector('meta[name="viewport"]')?.getAttribute("content") ??
"width=1300"
);
};
const updateViewport = (viewport: string): void => {
if (window.innerWidth < 500) {
const meta = document.querySelector('meta[name="viewport"]');
if (meta) {
meta.setAttribute("content", viewport);
} else {
const meta = document.createElement("meta");
meta.name = "viewport";
meta.content = viewport;
document.head.appendChild(meta);
}
}
};
const forwarded = useForwardPropsEmits(props, emits);
const initialViewport = ref(getViewport());
const openListener = (opened: boolean) => {
if (opened) {
updateViewport(MOBILE_VIEWPORT);
} else {
updateViewport(initialViewport.value);
}
};
onUnmounted(() => {
updateViewport(initialViewport.value);
});
</script>
<template>
<DialogRoot v-bind="forwarded" @update:open="openListener">
<slot />
</DialogRoot>
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import { DialogClose, type DialogCloseProps } from 'radix-vue'
const props = defineProps<DialogCloseProps>()
</script>
<template>
<DialogClose v-bind="props">
<slot />
</DialogClose>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import {
DialogClose,
DialogContent,
type DialogContentEmits,
type DialogContentProps,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from "radix-vue";
import { X } from "lucide-vue-next";
import { type SheetVariants, sheetVariants } from ".";
import { cn } from "@/lib/utils";
interface SheetContentProps extends DialogContentProps {
class?: HTMLAttributes["class"];
side?: SheetVariants["side"];
padding?: SheetVariants["padding"];
disabled?: boolean;
forceMount?: boolean;
to?: string | HTMLElement | Element;
}
defineOptions({
inheritAttrs: false,
});
const props = defineProps<SheetContentProps>();
const emits = defineEmits<DialogContentEmits>();
const delegatedProps = computed(() => {
const { class: _, side, padding, ...delegated } = props;
return delegated;
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DialogPortal
:disabled="disabled"
:force-mount="forceMount"
:to="to as HTMLElement"
>
<DialogOverlay
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/>
<DialogContent
:class="cn(sheetVariants({ side, padding }), props.class)"
v-bind="{ ...forwarded, ...$attrs }"
>
<slot />
<DialogClose
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"
>
<X class="w-4 h-4 text-muted-foreground" />
</DialogClose>
</DialogContent>
</DialogPortal>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import { DialogDescription, type DialogDescriptionProps } from "radix-vue";
import { cn } from "@/lib/utils";
const props = defineProps<
DialogDescriptionProps & { class?: HTMLAttributes["class"] }
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<DialogDescription
:class="cn('text-sm text-muted-foreground', props.class)"
v-bind="delegatedProps"
>
<slot />
</DialogDescription>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{ class?: HTMLAttributes["class"] }>();
</script>
<template>
<div
:class="
cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
props.class
)
"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{ class?: HTMLAttributes["class"] }>();
</script>
<template>
<div
:class="cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import { DialogTitle, type DialogTitleProps } from "radix-vue";
import { cn } from "@/lib/utils";
const props = defineProps<
DialogTitleProps & { class?: HTMLAttributes["class"] }
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<DialogTitle
:class="cn('text-lg font-medium text-foreground', props.class)"
v-bind="delegatedProps"
>
<slot />
</DialogTitle>
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import { DialogTrigger, type DialogTriggerProps } from 'radix-vue'
const props = defineProps<DialogTriggerProps>()
</script>
<template>
<DialogTrigger v-bind="props">
<slot />
</DialogTrigger>
</template>

View File

@@ -0,0 +1,36 @@
import { type VariantProps, cva } from "class-variance-authority";
export { default as Sheet } from "./Sheet.vue";
export { default as SheetTrigger } from "./SheetTrigger.vue";
export { default as SheetClose } from "./SheetClose.vue";
export { default as SheetContent } from "./SheetContent.vue";
export { default as SheetHeader } from "./SheetHeader.vue";
export { default as SheetTitle } from "./SheetTitle.vue";
export { default as SheetDescription } from "./SheetDescription.vue";
export { default as SheetFooter } from "./SheetFooter.vue";
export const sheetVariants = cva(
"fixed z-50 bg-muted dark:bg-background gap-4 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
padding: {
none: "",
md: "p-6",
},
},
defaultVariants: {
side: "right",
padding: "md",
},
}
);
export type SheetVariants = VariantProps<typeof sheetVariants>;

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import { TabsRoot, useForwardPropsEmits } from 'radix-vue'
import type { TabsRootEmits, TabsRootProps } from 'radix-vue'
const props = defineProps<TabsRootProps>()
const emits = defineEmits<TabsRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<TabsRoot v-bind="forwarded">
<slot />
</TabsRoot>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import { TabsContent, type TabsContentProps } from "radix-vue";
import { computed, type HTMLAttributes } from "vue";
const props = defineProps<
TabsContentProps & { class?: HTMLAttributes["class"] }
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<TabsContent
v-bind="delegatedProps"
:class="
cn(
'flex mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
props.class
)
"
class="data-[state=active]:flex data-[state=inactive]:hidden"
>
<slot />
</TabsContent>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import { TabsList, type TabsListProps } from "radix-vue";
import { cn } from "@/lib/utils";
const props = defineProps<
TabsListProps & { class?: HTMLAttributes["class"] }
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<TabsList
v-bind="delegatedProps"
:class="
cn(
'inline-flex items-center justify-center rounded-md bg-input p-1.5 text-foreground',
props.class
)
"
>
<slot />
</TabsList>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import { TabsTrigger, type TabsTriggerProps, useForwardProps } from "radix-vue";
import { cn } from "@/lib/utils";
const props = defineProps<
TabsTriggerProps & { class?: HTMLAttributes["class"] }
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<TabsTrigger
v-bind="forwardedProps"
:class="
cn(
'inline-flex items-center justify-center whitespace-nowrap rounded px-4.5 py-2.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
props.class
)
"
>
<span class="truncate">
<slot />
</span>
</TabsTrigger>
</template>

View File

@@ -0,0 +1,4 @@
export { default as Tabs } from './Tabs.vue'
export { default as TabsTrigger } from './TabsTrigger.vue'
export { default as TabsList } from './TabsList.vue'
export { default as TabsContent } from './TabsContent.vue'

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import { TooltipRoot, type TooltipRootEmits, type TooltipRootProps, useForwardPropsEmits } from 'radix-vue'
const props = defineProps<TooltipRootProps>()
const emits = defineEmits<TooltipRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<TooltipRoot v-bind="forwarded">
<slot />
</TooltipRoot>
</template>

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import {
TooltipContent,
type TooltipContentEmits,
type TooltipContentProps,
TooltipPortal,
useForwardPropsEmits,
} from "radix-vue";
import { cn } from "@/lib/utils";
import useTeleport from "@/composables/useTeleport";
defineOptions({
inheritAttrs: false,
});
const props = withDefaults(
defineProps<TooltipContentProps & { class?: HTMLAttributes["class"] }>(),
{
sideOffset: 4,
class: undefined,
}
);
const emits = defineEmits<TooltipContentEmits>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
const { teleportTarget } = useTeleport();
</script>
<template>
<TooltipPortal :to="teleportTarget as HTMLElement" defer>
<TooltipContent
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
props.class
)
"
>
<slot />
</TooltipContent>
</TooltipPortal>
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import { TooltipProvider, type TooltipProviderProps } from 'radix-vue'
const props = defineProps<TooltipProviderProps>()
</script>
<template>
<TooltipProvider v-bind="props">
<slot />
</TooltipProvider>
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import { TooltipTrigger, type TooltipTriggerProps } from 'radix-vue'
const props = defineProps<TooltipTriggerProps>()
</script>
<template>
<TooltipTrigger v-bind="props">
<slot />
</TooltipTrigger>
</template>

View File

@@ -0,0 +1,4 @@
export { default as Tooltip } from './Tooltip.vue'
export { default as TooltipContent } from './TooltipContent.vue'
export { default as TooltipTrigger } from './TooltipTrigger.vue'
export { default as TooltipProvider } from './TooltipProvider.vue'

View File

@@ -0,0 +1,85 @@
<script setup lang="ts">
/**
* @todo complete this component
*/
import { Switch, SwitchGroup, SwitchLabel } from "@headlessui/vue";
import { ref } from "vue";
withDefaults(
defineProps<{
description?: string; // @todo setup
label: string;
}>(),
{
description: "",
}
);
const checked = ref(false);
</script>
<template>
<SwitchGroup as="div">
<div class="flex flex-shrink-0 items-center gap-16px">
<Switch
v-model="checked"
:class="[
checked ? 'bg-green-500' : 'bg-gray-200',
'relative inline-flex h-24px w-[44px] flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2',
]"
>
<span
:class="[
checked ? 'translate-x-20px' : 'translate-x-0',
'pointer-events-none relative inline-block h-20px w-20px transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
]"
>
<span
:class="[
checked
? 'opacity-0 duration-100 ease-out'
: 'opacity-100 duration-200 ease-in',
'absolute inset-0 flex h-full w-full items-center justify-center transition-opacity',
]"
aria-hidden="true"
>
<svg
class="h-12px w-12px text-gray-400"
fill="none"
viewBox="0 0 12 12"
>
<path
d="M4 8l2-2m0 0l2-2M6 6L4 4m2 2l2 2"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
<span
:class="[
checked
? 'opacity-100 duration-200 ease-in'
: 'opacity-0 duration-100 ease-out',
'absolute inset-0 flex h-full w-full items-center justify-center transition-opacity',
]"
aria-hidden="true"
>
<svg
class="h-12px w-12px text-green-500"
fill="currentColor"
viewBox="0 0 12 12"
>
<path
d="M3.707 5.293a1 1 0 00-1.414 1.414l1.414-1.414zM5 8l-.707.707a1 1 0 001.414 0L5 8zm4.707-3.293a1 1 0 00-1.414-1.414l1.414 1.414zm-7.414 2l2 2 1.414-1.414-2-2-1.414 1.414zm3.414 2l4-4-1.414-1.414-4 4 1.414 1.414z"
/>
</svg>
</span>
</span>
</Switch>
<SwitchLabel class="text-14px">
{{ label }}
</SwitchLabel>
</div>
</SwitchGroup>
</template>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { ref } from "vue";
import { Switch, SwitchGroup, SwitchLabel } from "@headlessui/vue";
export interface Props {
label?: string;
// propChecked?: boolean;
}
withDefaults(defineProps<Props>(), {
label: "",
// propChecked: false,
});
const checked = ref(false);
</script>
<template>
<SwitchGroup>
<div class="flex items-center gap-8px p-8px rounded">
<Switch
v-model="checked"
:class="
checked
? 'bg-gradient-to-r from-unraid-red to-orange'
: 'bg-transparent'
"
class="relative inline-flex h-24px w-[48px] items-center rounded-full overflow-hidden"
>
<span
v-show="!checked"
class="absolute z-0 inset-0 opacity-10 bg-primary"
/>
<span
:class="checked ? 'translate-x-[26px]' : 'translate-x-[2px]'"
class="inline-block h-20px w-20px transform rounded-full bg-white transition"
/>
</Switch>
<SwitchLabel>{{ label }}</SwitchLabel>
</div>
</SwitchGroup>
</template>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { useVModel } from "@vueuse/core";
import { cn } from "@/lib/utils";
const props = defineProps<{
defaultValue?: string | number;
modelValue?: string | number;
class?: HTMLAttributes["class"];
}>();
const emits = defineEmits<{
(e: "update:modelValue", payload: string | number): void;
}>();
const modelValue = useVModel(props, "modelValue", emits, {
passive: true,
defaultValue: props.defaultValue,
});
</script>
<template>
<input
v-model="modelValue"
:class="
cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
props.class
)
"
/>
</template>

View File

@@ -0,0 +1 @@
export { default as Input } from './Input.vue'

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import { Label, type LabelProps } from "radix-vue";
import { cn } from "@/lib/utils";
const props = defineProps<LabelProps & { class?: HTMLAttributes["class"] }>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<Label
v-bind="delegatedProps"
:class="
cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
props.class
)
"
>
<slot />
</Label>
</template>

View File

@@ -0,0 +1 @@
export { default as Label } from './Label.vue'

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { SelectRootEmits, SelectRootProps } from 'radix-vue'
import { SelectRoot, useForwardPropsEmits } from 'radix-vue'
const props = defineProps<SelectRootProps>()
const emits = defineEmits<SelectRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<SelectRoot v-bind="forwarded">
<slot />
</SelectRoot>
</template>

View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import {
SelectContent,
type SelectContentEmits,
type SelectContentProps,
SelectPortal,
SelectViewport,
useForwardPropsEmits,
} from "radix-vue";
import { SelectScrollDownButton, SelectScrollUpButton } from ".";
import { cn } from "@/lib/utils";
defineOptions({
inheritAttrs: false,
});
const props = withDefaults(
defineProps<
SelectContentProps & {
class?: HTMLAttributes["class"];
disabled?: boolean;
forceMount?: boolean;
to?: string | HTMLElement | Element;
}
>(),
{
position: "popper",
class: undefined,
to: "#modals",
}
);
const emits = defineEmits<SelectContentEmits>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<SelectPortal
:disabled="disabled"
:force-mount="forceMount"
:to="to as HTMLElement"
>
<SelectContent
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class
)
"
>
<SelectScrollUpButton />
<SelectViewport
:class="
cn(
'p-1',
position === 'popper' &&
'h-[--radix-select-trigger-height] w-full min-w-[--radix-select-trigger-width]'
)
"
>
<slot />
</SelectViewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPortal>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import { SelectGroup, type SelectGroupProps } from "radix-vue";
import { cn } from "@/lib/utils";
const props = defineProps<
SelectGroupProps & { class?: HTMLAttributes["class"] }
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<SelectGroup :class="cn('p-1 w-full', props.class)" v-bind="delegatedProps">
<slot />
</SelectGroup>
</template>

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import {
SelectItem,
SelectItemIndicator,
type SelectItemProps,
SelectItemText,
useForwardProps,
} from "radix-vue";
import { Check } from "lucide-vue-next";
import { cn } from "@/lib/utils";
const props = defineProps<
SelectItemProps & { class?: HTMLAttributes["class"] }
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<SelectItem
v-bind="forwardedProps"
:class="
cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
props.class
)
"
>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectItemIndicator>
<Check class="h-4 w-4" />
</SelectItemIndicator>
</span>
<SelectItemText>
<slot />
</SelectItemText>
</SelectItem>
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import { SelectItemText, type SelectItemTextProps } from 'radix-vue'
const props = defineProps<SelectItemTextProps>()
</script>
<template>
<SelectItemText v-bind="props">
<slot />
</SelectItemText>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { SelectLabel, type SelectLabelProps } from "radix-vue";
import { cn } from "@/lib/utils";
const props = defineProps<
SelectLabelProps & { class?: HTMLAttributes["class"] }
>();
</script>
<template>
<SelectLabel
:class="cn('py-1.5 pl-8 pr-2 text-sm font-semibold', props.class)"
>
<slot />
</SelectLabel>
</template>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import {
SelectScrollDownButton,
type SelectScrollDownButtonProps,
useForwardProps,
} from "radix-vue";
import { ChevronDown } from "lucide-vue-next";
import { cn } from "@/lib/utils";
const props = defineProps<
SelectScrollDownButtonProps & { class?: HTMLAttributes["class"] }
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<SelectScrollDownButton
v-bind="forwardedProps"
:class="
cn('flex cursor-default items-center justify-center py-1', props.class)
"
>
<slot>
<ChevronDown class="h-4 w-4" />
</slot>
</SelectScrollDownButton>
</template>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import {
SelectScrollUpButton,
type SelectScrollUpButtonProps,
useForwardProps,
} from "radix-vue";
import { ChevronUp } from "lucide-vue-next";
import { cn } from "@/lib/utils";
const props = defineProps<
SelectScrollUpButtonProps & { class?: HTMLAttributes["class"] }
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<SelectScrollUpButton
v-bind="forwardedProps"
:class="
cn('flex cursor-default items-center justify-center py-1', props.class)
"
>
<slot>
<ChevronUp class="h-4 w-4" />
</slot>
</SelectScrollUpButton>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import { SelectSeparator, type SelectSeparatorProps } from "radix-vue";
import { cn } from "@/lib/utils";
const props = defineProps<
SelectSeparatorProps & { class?: HTMLAttributes["class"] }
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<SelectSeparator
v-bind="delegatedProps"
:class="cn('-mx-1 my-1 h-px bg-muted', props.class)"
/>
</template>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import {
SelectIcon,
SelectTrigger,
type SelectTriggerProps,
useForwardProps,
} from "radix-vue";
import { ChevronDown } from "lucide-vue-next";
import { cn } from "@/lib/utils";
const props = defineProps<
SelectTriggerProps & { class?: HTMLAttributes["class"] }
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<SelectTrigger
v-bind="forwardedProps"
:class="
cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-4.5 py-3 text-base ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate text-start',
props.class
)
"
>
<slot />
<SelectIcon as-child>
<ChevronDown class="w-4 h-4 opacity-50 shrink-0" />
</SelectIcon>
</SelectTrigger>
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import { SelectValue, type SelectValueProps } from 'radix-vue'
const props = defineProps<SelectValueProps>()
</script>
<template>
<SelectValue v-bind="props">
<slot />
</SelectValue>
</template>

View File

@@ -0,0 +1,11 @@
export { default as Select } from './Select.vue'
export { default as SelectValue } from './SelectValue.vue'
export { default as SelectTrigger } from './SelectTrigger.vue'
export { default as SelectContent } from './SelectContent.vue'
export { default as SelectGroup } from './SelectGroup.vue'
export { default as SelectItem } from './SelectItem.vue'
export { default as SelectItemText } from './SelectItemText.vue'
export { default as SelectLabel } from './SelectLabel.vue'
export { default as SelectSeparator } from './SelectSeparator.vue'
export { default as SelectScrollUpButton } from './SelectScrollUpButton.vue'
export { default as SelectScrollDownButton } from './SelectScrollDownButton.vue'

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import {
SwitchRoot,
type SwitchRootEmits,
type SwitchRootProps,
SwitchThumb,
useForwardPropsEmits,
} from "radix-vue";
import { cn } from "@/lib/utils";
const props = defineProps<
SwitchRootProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<SwitchRootEmits>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<SwitchRoot
v-bind="forwarded"
:class="
cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
props.class
)
"
>
<SwitchThumb
:class="
cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
)
"
/>
</SwitchRoot>
</template>

View File

@@ -0,0 +1,5 @@
import Switch from "./Switch.vue";
import SwitchHeadlessUI from "./SwitchHeadlessUI.vue";
import Lightswitch from "./Lightswitch.vue";
export { Switch, SwitchHeadlessUI, Lightswitch };

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
withDefaults(
defineProps<{
error?: boolean;
hover?: boolean;
increasedPadding?: boolean;
padding?: boolean;
warning?: boolean;
}>(),
{
error: false,
hover: true,
increasedPadding: false,
padding: true,
warning: false,
}
);
</script>
<template>
<div
class="group/card text-left relative flex flex-col flex-1 border-2 border-solid rounded-md shadow-md"
:class="[
padding && 'p-4',
increasedPadding && 'md:p-6',
hover && 'hover:shadow-orange/50 transition-all',
error && 'text-white bg-unraid-red border-unraid-red',
warning && 'text-black bg-yellow-100 border-yellow-100',
!error && !warning && 'text-foreground bg-background border-muted',
]"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
withDefaults(defineProps<{
maxWidth?: string;
}>(), {
maxWidth: 'max-w-1024px',
});
</script>
<template>
<div
class="grid gap-y-24px w-full mx-auto px-16px"
:class="maxWidth"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,4 @@
import CardWrapper from "./CardWrapper.vue";
import PageContainer from "./PageContainer.vue";
export { CardWrapper, PageContainer };

View File

@@ -0,0 +1,27 @@
import { ref, onMounted } from "vue";
const useTeleport = () => {
const teleportTarget = ref<string | HTMLElement | Element>("#modals");
const determineTeleportTarget = () => {
const myModalsComponent = document.querySelector("unraid-modals");
if (!myModalsComponent?.shadowRoot) return;
const potentialTarget = myModalsComponent.shadowRoot.querySelector("#modals");
if (!potentialTarget) return;
teleportTarget.value = potentialTarget;
console.log("[determineTeleportTarget] teleportTarget", teleportTarget.value);
};
onMounted(() => {
determineTeleportTarget();
});
return {
teleportTarget,
determineTeleportTarget,
};
};
export default useTeleport;

7
unraid-ui/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}

133
unraid-ui/src/index.ts Normal file
View File

@@ -0,0 +1,133 @@
// Lib
import { cn, scaleRemFactor } from "@/lib/utils";
// Components
import { Badge } from "@/components/common/badge";
import { Button, ButtonVariants } from "@/components/common/button";
import { CardWrapper, PageContainer } from "@/components/layout";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuRadioGroup,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuShortcut,
DropdownMenuSeparator,
DropdownMenuLabel,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
} from "@/components/common/dropdown-menu";
import { Bar, Error, Spinner } from "@/components/common/loading";
import { Input } from "@/components/form/input";
import { Label } from "@/components/form/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectItemText,
SelectLabel,
SelectScrollUpButton,
SelectScrollDownButton,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/form/select";
import {
Switch,
SwitchHeadlessUI,
Lightswitch,
} from "@/components/form/switch";
import {
Tabs,
TabsList,
TabsTrigger,
TabsContent,
} from "@/components/common/tabs";
import { ScrollArea, ScrollBar } from "@/components/common/scroll-area";
import {
Sheet,
SheetTrigger,
SheetContent,
SheetClose,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
} from "@/components/common/sheet";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
TooltipProvider,
} from "@/components/common/tooltip";
// Composables
import useTeleport from "@/composables/useTeleport";
// Export
export {
Bar,
Badge,
Button,
ButtonVariants,
CardWrapper,
cn,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuRadioGroup,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuShortcut,
DropdownMenuSeparator,
DropdownMenuLabel,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
Error,
Input,
Label,
PageContainer,
scaleRemFactor,
ScrollBar,
ScrollArea,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectItemText,
SelectLabel,
SelectScrollUpButton,
SelectScrollDownButton,
SelectSeparator,
SelectTrigger,
SelectValue,
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
Spinner,
Switch,
SwitchHeadlessUI,
Lightswitch,
Tabs,
TabsList,
TabsTrigger,
TabsContent,
Tooltip,
TooltipContent,
TooltipTrigger,
TooltipProvider,
useTeleport,
};

View File

@@ -0,0 +1,27 @@
import type { Config } from "tailwindcss";
import { scaleRemFactor } from "../utils";
import defaultTheme from "tailwindcss/defaultTheme";
import plugin from "tailwindcss/plugin";
export default plugin.withOptions(
() => {
return function () {
// Plugin functionality can be added here if needed in the future
};
},
(options?: {
baseFontSize?: number;
newFontSize?: number;
}): Partial<Config> => {
const baseFontSize = options?.baseFontSize ?? 16;
const newFontSize = options?.newFontSize ?? 10;
return {
theme: scaleRemFactor(
defaultTheme,
baseFontSize,
newFontSize
) as Config["theme"],
};
}
);

View File

@@ -0,0 +1,59 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
type RemToPxInput = unknown;
function isFunction(
input: RemToPxInput
): input is (...args: unknown[]) => boolean {
return typeof input === "function";
}
export function scaleRemFactor(
input: RemToPxInput,
baseFontSize = 16,
newFontSize = 10
): unknown {
const scaleFactor = baseFontSize / newFontSize; // baseFontSize / newFontSize;
if (input == null) return input;
if (typeof input === "string") {
return input.replace(
/(\d*\.?\d+)rem$/,
(_, val) => `${(parseFloat(val) * scaleFactor).toFixed(4)}rem`
);
}
if (Array.isArray(input))
return input.map((val) => scaleRemFactor(val, baseFontSize, newFontSize));
if (typeof input === "object") {
const ret: Record<string, RemToPxInput> = {};
const obj = input as Record<string, RemToPxInput>;
for (const key in obj) {
ret[key] = scaleRemFactor(obj[key], baseFontSize, newFontSize);
}
return ret;
}
if (isFunction(input)) {
return function (...args: unknown[]): unknown {
const replacedArgs = args.map((arg) => {
if (typeof arg === "string") {
return arg.replace(
/(\d*\.?\d+)rem/g,
(_, val) => `${(parseFloat(val) * scaleFactor).toFixed(4)}rem`
);
}
return arg;
});
return input(...replacedArgs);
};
}
return input;
}

View File

@@ -0,0 +1,55 @@
/* @tailwind directives */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,2 @@
/* global styles for unraid-ui */
@import "./global.css";

View File

@@ -0,0 +1,26 @@
import type { XCircleIcon } from "@heroicons/vue/24/solid";
import type { Component } from "vue";
export type UiBadgePropsColor =
| "gray"
| "red"
| "yellow"
| "green"
| "blue"
| "indigo"
| "purple"
| "pink"
| "orange"
| "black"
| "white"
| "transparent"
| "current"
| "custom";
export interface UiBadgeProps {
color?: UiBadgePropsColor;
icon?: typeof XCircleIcon | Component;
iconRight?: typeof XCircleIcon | Component;
iconStyles?: string;
size?: "12px" | "14px" | "16px" | "18px" | "20px" | "24px";
}

View File

@@ -0,0 +1,31 @@
import type { Component } from "vue";
export type ButtonStyle =
| "black"
| "fill"
| "gray"
| "outline"
| "outline-black"
| "outline-white"
| "underline"
| "underline-hover-red"
| "white"
| "none";
export interface ButtonProps {
btnStyle?: ButtonStyle;
btnType?: "button" | "submit" | "reset";
class?: string | string[] | Record<string, boolean> | undefined;
click?: () => void;
disabled?: boolean;
download?: boolean;
external?: boolean;
href?: string;
icon?: Component;
iconRight?: Component;
iconRightHoverDisplay?: boolean;
// iconRightHoverAnimate?: boolean;
noPadding?: boolean;
size?: "12px" | "14px" | "16px" | "18px" | "20px" | "24px";
text?: string;
title?: string;
}

View File

@@ -0,0 +1,56 @@
import type { Meta, StoryObj } from "@storybook/vue3";
import ButtonComponent from "../../../src/components/common/button/Button.vue";
interface ButtonStoryProps {
variant:
| "primary"
| "destructive"
| "outline"
| "secondary"
| "ghost"
| "link";
size: "sm" | "md" | "lg" | "icon";
text: string;
}
const meta = {
title: "Components/Common",
component: ButtonComponent,
argTypes: {
variant: {
control: "select",
options: [
"primary",
"destructive",
"outline",
"secondary",
"ghost",
"link",
],
},
size: {
control: "select",
options: ["sm", "md", "lg", "icon"],
},
},
} satisfies Meta<typeof ButtonComponent>;
export default meta;
type Story = StoryObj<ButtonStoryProps>;
export const Button: Story = {
args: {
variant: "primary",
size: "md",
text: "Click me",
},
render: (args) => ({
components: { ButtonComponent },
setup() {
return { args };
},
template:
'<ButtonComponent :variant="args.variant" :size="args.size">{{ args.text }}</ButtonComponent>',
}),
};

View File

@@ -0,0 +1,274 @@
import "dotenv/config";
import type { Config } from "tailwindcss";
import type { PluginAPI } from "tailwindcss/types/config";
import typography from "@tailwindcss/typography";
import animate from "tailwindcss-animate";
import remToRem from "./src/lib/tailwind-rem-to-rem";
export default <Partial<Config>>{
content: ["./src/components/**/*.{js,vue,ts}", "./src/composables/**/*.vue"],
darkMode: ["selector"],
safelist: [
"dark",
"DropdownWrapper_blip",
"unraid_mark_1",
"unraid_mark_2",
"unraid_mark_3",
"unraid_mark_4",
"unraid_mark_6",
"unraid_mark_7",
"unraid_mark_8",
"unraid_mark_9",
],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
fontFamily: {
sans: "clear-sans,ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji",
},
colors: {
inherit: "inherit",
transparent: "transparent",
black: "#1c1b1b",
"grey-darkest": "#222",
"grey-darker": "#606f7b",
"grey-dark": "#383735",
"grey-mid": "#999999",
grey: "#e0e0e0",
"grey-light": "#dae1e7",
"grey-lighter": "#f1f5f8",
"grey-lightest": "#f2f2f2",
white: "#ffffff",
// unraid colors
"yellow-accent": "#E9BF41",
"orange-dark": "#f15a2c",
orange: "#ff8c2f",
// palettes generated from https://uicolors.app/create
"unraid-red": {
DEFAULT: "#E22828",
"50": "#fef2f2",
"100": "#ffe1e1",
"200": "#ffc9c9",
"300": "#fea3a3",
"400": "#fc6d6d",
"500": "#f43f3f",
"600": "#e22828",
"700": "#bd1818",
"800": "#9c1818",
"900": "#821a1a",
"950": "#470808",
},
"unraid-green": {
DEFAULT: "#63A659",
"50": "#f5f9f4",
"100": "#e7f3e5",
"200": "#d0e6cc",
"300": "#aad1a4",
"400": "#7db474",
"500": "#63a659",
"600": "#457b3e",
"700": "#396134",
"800": "#314e2d",
"900": "#284126",
"950": "#122211",
},
"header-text-primary": "var(--header-text-primary)",
"header-text-secondary": "var(--header-text-secondary)",
"header-background-color": "var(--header-background-color)",
// ShadCN
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
// Unfortunately due to webGUI CSS setting base HTML font-size to .65% or something we must use pixel values for web components
fontSize: {
"10px": "10px",
"12px": "12px",
"14px": "14px",
"16px": "16px",
"18px": "18px",
"20px": "20px",
"24px": "24px",
"30px": "30px",
},
spacing: {
"4.5": "1.125rem",
"-8px": "-8px",
"2px": "2px",
"4px": "4px",
"6px": "6px",
"8px": "8px",
"10px": "10px",
"12px": "12px",
"14px": "14px",
"16px": "16px",
"20px": "20px",
"24px": "24px",
"28px": "28px",
"32px": "32px",
"36px": "36px",
"40px": "40px",
"64px": "64px",
"80px": "80px",
"90px": "90px",
"150px": "150px",
"160px": "160px",
"200px": "200px",
"260px": "260px",
"300px": "300px",
"310px": "310px",
"350px": "350px",
"448px": "448px",
"512px": "512px",
"640px": "640px",
"800px": "800px",
},
minWidth: {
"86px": "86px",
"160px": "160px",
"260px": "260px",
"300px": "300px",
"310px": "310px",
"350px": "350px",
"800px": "800px",
},
maxWidth: {
"86px": "86px",
"160px": "160px",
"260px": "260px",
"300px": "300px",
"310px": "310px",
"350px": "350px",
"640px": "640px",
"800px": "800px",
"1024px": "1024px",
},
screens: {
"2xs": "470px",
xs: "530px",
tall: { raw: "(min-height: 700px)" },
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
"collapsible-down": {
from: { height: "0" },
to: { height: "var(--radix-collapsible-content-height)" },
},
"collapsible-up": {
from: { height: "var(--radix-collapsible-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"collapsible-down": "collapsible-down 0.2s ease-in-out",
"collapsible-up": "collapsible-up 0.2s ease-in-out",
},
/**
* @todo modify prose classes to use pixels for webgui…sadge https://tailwindcss.com/docs/typography-plugin#customizing-the-default-theme
*/
typography: (theme: PluginAPI["theme"]) => ({
DEFAULT: {
css: {
color: theme("colors.foreground"),
a: {
color: theme("colors.primary"),
textDecoration: "underline",
"&:hover": {
color: theme("colors.primary-foreground"),
},
},
"--tw-prose-body": theme("colors.foreground"),
"--tw-prose-headings": theme("colors.foreground"),
"--tw-prose-lead": theme("colors.foreground"),
"--tw-prose-links": theme("colors.primary"),
"--tw-prose-bold": theme("colors.foreground"),
"--tw-prose-counters": theme("colors.foreground"),
"--tw-prose-bullets": theme("colors.foreground"),
"--tw-prose-hr": theme("colors.foreground"),
"--tw-prose-quotes": theme("colors.foreground"),
"--tw-prose-quote-borders": theme("colors.foreground"),
"--tw-prose-captions": theme("colors.foreground"),
"--tw-prose-code": theme("colors.foreground"),
"--tw-prose-pre-code": theme("colors.foreground"),
"--tw-prose-pre-bg": theme("colors.background"),
"--tw-prose-th-borders": theme("colors.foreground"),
"--tw-prose-td-borders": theme("colors.foreground"),
"--tw-prose-invert-body": theme("colors.background"),
"--tw-prose-invert-headings": theme("colors.background"),
"--tw-prose-invert-lead": theme("colors.background"),
"--tw-prose-invert-links": theme("colors.primary"),
"--tw-prose-invert-bold": theme("colors.background"),
"--tw-prose-invert-counters": theme("colors.background"),
"--tw-prose-invert-bullets": theme("colors.background"),
"--tw-prose-invert-hr": theme("colors.background"),
"--tw-prose-invert-quotes": theme("colors.background"),
"--tw-prose-invert-quote-borders": theme("colors.background"),
"--tw-prose-invert-captions": theme("colors.background"),
"--tw-prose-invert-code": theme("colors.background"),
"--tw-prose-invert-pre-code": theme("colors.background"),
"--tw-prose-invert-pre-bg": theme("colors.foreground"),
"--tw-prose-invert-th-borders": theme("colors.background"),
"--tw-prose-invert-td-borders": theme("colors.background"),
},
},
}),
},
},
plugins: [
typography,
animate,
remToRem({
baseFontSize: 16,
newFontSize: Number(process.env.VITE_TAILWIND_BASE_FONT_SIZE) || 10,
}),
],
};

10
unraid-ui/test/setup.ts Normal file
View File

@@ -0,0 +1,10 @@
/// <reference types="vitest/globals" />
import { config } from "@vue/test-utils";
import { cleanup } from "@testing-library/vue";
// Setup Vue Test Utils global config
config.global.stubs = {};
afterEach(() => {
cleanup();
});

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@/components/*": ["./src/components/*"],
"@/lib/*": ["./src/lib/*"],
"@/types/*": ["./src/types/*"]
},
"types": ["vite/client", "vitest/globals"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}

20
unraid-ui/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"extends": "./tsconfig.app.json",
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@/styles": ["./src/styles"],
"@/components": ["./src/components"],
"@/composables": ["./src/composables"],
"@/lib": ["./src/lib"],
"@/types": ["./src/types"]
},
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"exclude": ["node_modules", "**/*.copy.vue", "**/*copy.vue"],
"references": [{ "path": "./tsconfig.test.json" }]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,17 @@
{
"extends": "./tsconfig.app.json",
"compilerOptions": {
"composite": true,
"emitDeclarationOnly": true,
"outDir": "./dist/test",
"types": [
"node",
"happy-dom",
"vitest/globals",
"@vue/test-utils",
"@testing-library/vue"
]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.copy.vue", "**/*copy.vue"]
}

56
unraid-ui/vite.config.ts Normal file
View File

@@ -0,0 +1,56 @@
/// <reference types="vitest" />
import { defineConfig } from "vite";
import { resolve } from "path";
import vue from "@vitejs/plugin-vue";
import dts from "vite-plugin-dts";
export default defineConfig({
plugins: [
vue(),
dts({
insertTypesEntry: true,
include: ["src/**/*.ts", "src/**/*.vue"],
}),
],
build: {
lib: {
entry: resolve(__dirname, "src/index.ts"),
name: "Unraid UI",
formats: ["es", "umd"],
fileName: "index",
},
cssCodeSplit: true,
minify: true,
sourcemap: true,
rollupOptions: {
external: ["vue"],
output: {
assetFileNames: (assetInfo) => {
if (
typeof assetInfo.source === "string" &&
assetInfo.source.includes("style.css")
)
return "css/style.[hash].css";
return "assets/[name].[hash][extname]";
},
globals: {
vue: "Vue",
},
},
},
},
resolve: {
alias: {
"@": resolve(__dirname, "./src"),
"@/components": resolve(__dirname, "./src/components"),
"@/composables": resolve(__dirname, "./src/composables"),
"@/lib": resolve(__dirname, "./src/lib"),
"@/styles": resolve(__dirname, "./src/styles"),
"@/types": resolve(__dirname, "./src/types"),
},
},
test: {
environment: "happy-dom",
include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
},
});

View File

@@ -0,0 +1,21 @@
/// <reference types="vitest" />
import { defineConfig } from "vitest/config";
import vue from "@vitejs/plugin-vue";
import { resolve } from "path";
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: "happy-dom",
include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
},
resolve: {
alias: {
"@": resolve(__dirname, "./src"),
"@/components": resolve(__dirname, "./src/components"),
"@/lib": resolve(__dirname, "./src/lib"),
"@/types": resolve(__dirname, "./src/types"),
},
},
});

View File

@@ -4,6 +4,12 @@ import type { PluginAPI } from 'tailwindcss/types/config';
// @ts-expect-error - just trying to get this to build @fixme
export default <Partial<Config>>{
content: [
'./components/**/*.{js,vue,ts}',
'./layouts/**/*.vue',
'./pages/**/*.vue',
'../unraid-ui/src/**/*.{vue,ts}',
],
darkMode: ['selector'],
safelist: [
'dark',