feat: build out docker components (#1427)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Introduced comprehensive, customizable Accordion, Dialog,
DropdownMenu, and Select UI components with enhanced prop-driven and
slot-based APIs.
* Added grouped exports for UI primitives, simplifying imports and
usage.
* Added new Storybook stories demonstrating varied usage scenarios for
Accordion, Dialog, DropdownMenu, and Select components.

* **Refactor**
* Replaced external UI dependencies with locally defined, typed
components for Accordion, Dialog, DropdownMenu, and Select.
* Streamlined component APIs by consolidating exports to main components
and type exports, removing subcomponent exports.
* Simplified dialog and dropdown menu implementations with explicit
props, events, and slots.
* Updated component styles and class bindings for improved appearance
and interaction.
* Refined select component into a fully featured, typed implementation
supporting grouping and multiple selection.
* Replaced custom dropdown menu implementation in user profile with the
new DropdownMenu component.
* Simplified internal prop forwarding using reactive utilities for
dropdown menu and select subcomponents.
* Improved dropdown menu stories with declarative props and slots,
removing manual subcomponent composition.
* Simplified notification filter UI by replacing nested select
subcomponents with a declarative items prop.

* **Bug Fixes**
* Improved dropdown and select item handling, including disabled states,
separators, and grouped options.

* **Style**
* Enhanced visual consistency and spacing in documentation and UI
components.
  * Updated component classes for better appearance and usability.

* **Chores**
* Upgraded `@jsonforms` dependencies across all packages to version
`^3.6.0`.
  * Improved test and mock setups for new component structures.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: mdatelle <mike@datelle.net>
Co-authored-by: Eli Bosley <ekbosley@gmail.com>
This commit is contained in:
Michael Datelle
2025-07-03 16:40:06 -04:00
committed by GitHub
parent 0ec0de982f
commit 711cc9ac92
77 changed files with 7940 additions and 5048 deletions

View File

@@ -15,6 +15,7 @@ This is the Unraid API monorepo containing multiple packages that provide API fu
## Essential Commands
### Development
```bash
pnpm install # Install all dependencies
pnpm dev # Run all dev servers concurrently
@@ -23,6 +24,7 @@ pnpm build:watch # Watch mode with local plugin build
```
### Testing & Code Quality
```bash
pnpm test # Run all tests
pnpm lint # Run linting
@@ -31,6 +33,7 @@ pnpm type-check # TypeScript type checking
```
### API Development
```bash
cd api && pnpm dev # Run API server (http://localhost:3001)
cd api && pnpm test:watch # Run tests in watch mode
@@ -38,6 +41,7 @@ cd api && pnpm codegen # Generate GraphQL types
```
### Deployment
```bash
pnpm unraid:deploy <SERVER_IP> # Deploy all to Unraid server
```
@@ -45,6 +49,7 @@ pnpm unraid:deploy <SERVER_IP> # Deploy all to Unraid server
## Architecture Notes
### API Structure (NestJS)
- Modules: `auth`, `config`, `plugins`, `emhttp`, `monitoring`
- GraphQL API with Apollo Server at `/graphql`
- Redux store for state management in `src/store/`
@@ -52,26 +57,31 @@ pnpm unraid:deploy <SERVER_IP> # Deploy all to Unraid server
- Entry points: `src/index.ts` (server), `src/cli.ts` (CLI)
### Key Patterns
- TypeScript imports use `.js` extensions (ESM compatibility)
- NestJS dependency injection with decorators
- GraphQL schema-first approach with code generation
- API plugins follow specific structure (see `api/docs/developer/api-plugins.md`)
### Authentication
- API key authentication via headers
- Cookie-based session management
- Keys stored in `/boot/config/plugins/unraid-api/`
### Development Workflow
1. Work Intent required before starting development
2. Fork from `main` branch
3. Reference Work Intent in PR
4. No direct pushes to main
### Debug Mode
```bash
LOG_LEVEL=debug unraid-api start --debug
```
Enables GraphQL playground at `http://tower.local/graphql`
## Coding Guidelines

View File

@@ -52,29 +52,29 @@
"unraid-api": "dist/cli.js"
},
"dependencies": {
"@apollo/client": "^3.11.8",
"@apollo/server": "^4.11.2",
"@apollo/client": "^3.13.8",
"@apollo/server": "^4.12.2",
"@as-integrations/fastify": "^2.1.1",
"@fastify/cookie": "^11.0.2",
"@fastify/helmet": "^13.0.1",
"@graphql-codegen/client-preset": "^4.5.0",
"@graphql-tools/load-files": "^7.0.0",
"@graphql-tools/merge": "^9.0.8",
"@graphql-tools/schema": "^10.0.7",
"@graphql-tools/utils": "^10.5.5",
"@jsonforms/core": "^3.5.1",
"@nestjs/apollo": "^13.0.3",
"@graphql-codegen/client-preset": "^4.8.3",
"@graphql-tools/load-files": "^7.0.1",
"@graphql-tools/merge": "^9.0.24",
"@graphql-tools/schema": "^10.0.23",
"@graphql-tools/utils": "^10.8.6",
"@jsonforms/core": "^3.6.0",
"@nestjs/apollo": "^13.1.0",
"@nestjs/cache-manager": "^3.0.1",
"@nestjs/common": "^11.0.11",
"@nestjs/common": "^11.1.3",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.11",
"@nestjs/core": "^11.1.3",
"@nestjs/event-emitter": "^3.0.1",
"@nestjs/graphql": "^13.0.3",
"@nestjs/passport": "^11.0.0",
"@nestjs/platform-fastify": "^11.0.11",
"@nestjs/graphql": "^13.1.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-fastify": "^11.1.3",
"@nestjs/schedule": "^6.0.0",
"@nestjs/throttler": "^6.4.0",
"@reduxjs/toolkit": "^2.3.0",
"@reduxjs/toolkit": "^2.8.2",
"@runonflux/nat-upnp": "^1.0.2",
"@types/diff": "^8.0.0",
"@unraid/libvirt": "^2.1.0",
@@ -82,67 +82,67 @@
"accesscontrol": "^2.2.1",
"bycontract": "^2.0.11",
"bytes": "^3.1.2",
"cache-manager": "^7.0.0",
"cache-manager": "^7.0.1",
"cacheable-lookup": "^7.0.0",
"camelcase-keys": "^9.1.3",
"casbin": "^5.32.0",
"casbin": "^5.38.0",
"change-case": "^5.4.4",
"chokidar": "^4.0.1",
"chokidar": "^4.0.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"class-validator": "^0.14.2",
"cli-table": "^0.3.11",
"command-exists": "^1.2.9",
"convert": "^5.8.0",
"convert": "^5.12.0",
"cookie": "^1.0.2",
"cron": "4.3.1",
"cross-fetch": "^4.0.0",
"diff": "^8.0.0",
"dockerode": "^4.0.5",
"dotenv": "^16.4.5",
"execa": "^9.5.1",
"cross-fetch": "^4.1.0",
"diff": "^8.0.2",
"dockerode": "^4.0.7",
"dotenv": "^16.6.1",
"execa": "^9.6.0",
"exit-hook": "^4.0.0",
"fastify": "^5.2.1",
"fastify": "^5.4.0",
"filenamify": "^6.0.0",
"fs-extra": "^11.2.0",
"glob": "^11.0.1",
"fs-extra": "^11.3.0",
"glob": "^11.0.3",
"global-agent": "^3.0.0",
"got": "^14.4.6",
"graphql": "^16.9.0",
"got": "^14.4.7",
"graphql": "^16.11.0",
"graphql-fields": "^2.0.3",
"graphql-scalars": "^1.23.0",
"graphql-scalars": "^1.24.2",
"graphql-subscriptions": "^3.0.0",
"graphql-tag": "^2.12.6",
"graphql-ws": "^6.0.0",
"graphql-ws": "^6.0.5",
"ini": "^5.0.0",
"ip": "^2.0.1",
"jose": "^6.0.0",
"jose": "^6.0.11",
"json-bigint-patch": "^0.0.8",
"lodash-es": "^4.17.21",
"multi-ini": "^2.3.2",
"mustache": "^4.2.0",
"nest-authz": "^2.14.0",
"nest-commander": "^3.15.0",
"nestjs-pino": "^4.1.0",
"nest-authz": "^2.17.0",
"nest-commander": "^3.17.0",
"nestjs-pino": "^4.4.0",
"node-cache": "^5.1.2",
"node-window-polyfill": "^1.0.2",
"p-retry": "^6.2.0",
"node-window-polyfill": "^1.0.4",
"p-retry": "^6.2.1",
"passport-custom": "^1.1.1",
"passport-http-header-strategy": "^1.1.0",
"path-type": "^6.0.0",
"pino": "^9.5.0",
"pino-http": "^10.3.0",
"pino": "^9.7.0",
"pino-http": "^10.5.0",
"pino-pretty": "^13.0.0",
"pm2": "^6.0.0",
"pm2": "^6.0.8",
"reflect-metadata": "^0.1.14",
"request": "^2.88.2",
"rxjs": "^7.8.2",
"semver": "^7.6.3",
"semver": "^7.7.2",
"strftime": "^0.10.3",
"systeminformation": "^5.25.11",
"uuid": "^11.0.2",
"ws": "^8.18.0",
"systeminformation": "^5.27.7",
"uuid": "^11.1.0",
"ws": "^8.18.3",
"zen-observable-ts": "^1.1.0",
"zod": "^3.23.8"
"zod": "^3.25.69"
},
"peerDependencies": {
"unraid-api-plugin-connect": "workspace:*"
@@ -153,67 +153,67 @@
}
},
"devDependencies": {
"@eslint/js": "^9.21.0",
"@eslint/js": "^9.30.1",
"@graphql-codegen/add": "^5.0.3",
"@graphql-codegen/cli": "^5.0.3",
"@graphql-codegen/fragment-matcher": "^5.0.2",
"@graphql-codegen/import-types-preset": "^3.0.0",
"@graphql-codegen/typed-document-node": "^5.0.11",
"@graphql-codegen/typescript": "^4.1.1",
"@graphql-codegen/typescript-operations": "^4.3.1",
"@graphql-codegen/cli": "^5.0.7",
"@graphql-codegen/fragment-matcher": "^5.1.0",
"@graphql-codegen/import-types-preset": "^3.0.1",
"@graphql-codegen/typed-document-node": "^5.1.2",
"@graphql-codegen/typescript": "^4.1.6",
"@graphql-codegen/typescript-operations": "^4.6.1",
"@graphql-codegen/typescript-resolvers": "4.5.1",
"@graphql-typed-document-node/core": "^3.2.0",
"@ianvs/prettier-plugin-sort-imports": "^4.4.0",
"@nestjs/testing": "^11.0.11",
"@ianvs/prettier-plugin-sort-imports": "^4.4.2",
"@nestjs/testing": "^11.1.3",
"@originjs/vite-plugin-commonjs": "^1.0.3",
"@rollup/plugin-node-resolve": "^16.0.0",
"@swc/core": "^1.10.1",
"@rollup/plugin-node-resolve": "^16.0.1",
"@swc/core": "^1.12.9",
"@types/async-exit-hook": "^2.0.2",
"@types/bytes": "^3.1.4",
"@types/bytes": "^3.1.5",
"@types/cli-table": "^0.3.4",
"@types/command-exists": "^1.2.3",
"@types/cors": "^2.8.17",
"@types/dockerode": "^3.3.31",
"@types/cors": "^2.8.19",
"@types/dockerode": "^3.3.42",
"@types/graphql-fields": "^1.3.9",
"@types/graphql-type-uuid": "^0.2.6",
"@types/ini": "^4.1.1",
"@types/ip": "^1.1.3",
"@types/lodash": "^4.17.13",
"@types/lodash": "^4.17.20",
"@types/lodash-es": "^4.17.12",
"@types/mustache": "^4.2.5",
"@types/node": "^22.13.4",
"@types/pify": "^6.0.0",
"@types/semver": "^7.5.8",
"@types/mustache": "^4.2.6",
"@types/node": "^22.16.0",
"@types/pify": "^6.1.0",
"@types/semver": "^7.7.0",
"@types/sendmail": "^1.4.7",
"@types/stoppable": "^1.1.3",
"@types/strftime": "^0.9.8",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.5.13",
"@types/ws": "^8.18.1",
"@types/wtfnode": "^0.7.3",
"@vitest/coverage-v8": "^3.0.5",
"@vitest/ui": "^3.0.5",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"cz-conventional-changelog": "3.3.0",
"eslint": "^9.20.1",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-n": "^17.0.0",
"eslint": "^9.30.1",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-n": "^17.20.0",
"eslint-plugin-no-relative-import-paths": "^1.6.1",
"eslint-plugin-prettier": "^5.2.3",
"graphql-codegen-typescript-validation-schema": "^0.17.0",
"jiti": "^2.4.0",
"nodemon": "^3.1.7",
"prettier": "^3.5.2",
"rollup-plugin-node-externals": "^8.0.0",
"eslint-plugin-prettier": "^5.5.1",
"graphql-codegen-typescript-validation-schema": "^0.17.1",
"jiti": "^2.4.2",
"nodemon": "^3.1.10",
"prettier": "^3.6.2",
"rollup-plugin-node-externals": "^8.0.1",
"standard-version": "^9.5.0",
"tsx": "^4.19.3",
"type-fest": "^4.37.0",
"typescript": "^5.6.3",
"typescript-eslint": "^8.13.0",
"unplugin-swc": "^1.5.1",
"vite": "^6.0.0",
"vite-plugin-node": "^5.0.0",
"vite-tsconfig-paths": "^5.1.0",
"vitest": "^3.0.5",
"zx": "^8.3.2"
"tsx": "^4.20.3",
"type-fest": "^4.41.0",
"typescript": "^5.8.3",
"typescript-eslint": "^8.35.1",
"unplugin-swc": "^1.5.5",
"vite": "^6.3.5",
"vite-plugin-node": "^5.0.1",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4",
"zx": "^8.6.1"
},
"overrides": {
"eslint": {

View File

@@ -29,7 +29,7 @@
"@graphql-codegen/cli": "^5.0.3",
"@graphql-typed-document-node/core": "^3.2.0",
"@ianvs/prettier-plugin-sort-imports": "^4.4.1",
"@jsonforms/core": "^3.5.1",
"@jsonforms/core": "^3.6.0",
"@nestjs/apollo": "^13.0.3",
"@nestjs/common": "^11.0.11",
"@nestjs/config": "^4.0.2",
@@ -49,7 +49,7 @@
"execa": "^9.5.1",
"fast-check": "^4.1.1",
"got": "^14.4.6",
"graphql": "^16.9.0",
"graphql": "^16.11.0",
"graphql-scalars": "^1.23.0",
"graphql-subscriptions": "^3.0.0",
"graphql-ws": "^6.0.0",
@@ -74,7 +74,7 @@
"peerDependencies": {
"@apollo/client": "^3.11.8",
"@graphql-typed-document-node/core": "^3.2.0",
"@jsonforms/core": "^3.5.1",
"@jsonforms/core": "^3.6.0",
"@nestjs/apollo": "^13.0.3",
"@nestjs/common": "^11.0.11",
"@nestjs/config": "^4.0.2",
@@ -88,7 +88,7 @@
"class-validator": "^0.14.1",
"execa": "^9.5.1",
"got": "^14.4.6",
"graphql": "^16.9.0",
"graphql": "^16.11.0",
"graphql-scalars": "^1.23.0",
"graphql-subscriptions": "^3.0.0",
"graphql-ws": "^6.0.0",

View File

@@ -28,14 +28,14 @@
"description": "Shared utilities and types for Unraid API ecosystem",
"devDependencies": {
"@graphql-tools/utils": "^10.5.5",
"@jsonforms/core": "^3.5.1",
"@jsonforms/core": "^3.6.0",
"@nestjs/common": "^11.0.11",
"@nestjs/graphql": "^13.0.3",
"@types/bun": "^1.2.15",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.14.0",
"class-validator": "^0.14.1",
"graphql": "^16.9.0",
"graphql": "^16.11.0",
"graphql-scalars": "^1.23.0",
"lodash-es": "^4.17.21",
"nest-authz": "^2.14.0",
@@ -45,11 +45,11 @@
},
"peerDependencies": {
"@graphql-tools/utils": "^10.5.5",
"@jsonforms/core": "^3.5.1",
"@jsonforms/core": "^3.6.0",
"@nestjs/common": "^11.0.11",
"@nestjs/graphql": "^13.0.3",
"class-validator": "^0.14.1",
"graphql": "^16.9.0",
"graphql": "^16.11.0",
"graphql-scalars": "^1.23.0",
"lodash-es": "^4.17.21",
"nest-authz": "^2.14.0"

10230
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,7 @@
"prefix": ""
},
"aliases": {
"components": "@/components/common",
"components": "@/components",
"composables": "@/composables",
"utils": "@/lib/utils",
"lib": "@/lib"

View File

@@ -48,9 +48,9 @@
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.2.0",
"@internationalized/number": "^3.6.0",
"@jsonforms/core": "^3.5.1",
"@jsonforms/vue": "^3.5.1",
"@jsonforms/vue-vanilla": "^3.5.1",
"@jsonforms/core": "^3.6.0",
"@jsonforms/vue": "^3.6.0",
"@jsonforms/vue-vanilla": "^3.6.0",
"@vueuse/core": "^13.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View File

@@ -1,19 +1,61 @@
<script setup lang="ts">
import {
AccordionContent,
AccordionItem,
AccordionRoot,
useForwardPropsEmits,
type AccordionRootEmits,
type AccordionRootProps,
} from 'reka-ui';
AccordionTrigger,
} from '@/components/ui/accordion';
const props = defineProps<AccordionRootProps>();
const emits = defineEmits<AccordionRootEmits>();
export interface AccordionItemData {
value: string;
title: string;
content?: string;
disabled?: boolean;
}
const forwarded = useForwardPropsEmits(props, emits);
export interface AccordionProps {
items?: AccordionItemData[];
type?: 'single' | 'multiple';
collapsible?: boolean;
defaultValue?: string | string[];
class?: string;
}
const props = withDefaults(defineProps<AccordionProps>(), {
type: 'single',
collapsible: true,
});
</script>
<template>
<AccordionRoot v-bind="forwarded">
<AccordionRoot
:type="type"
:collapsible="collapsible"
:default-value="defaultValue"
:class="props.class"
>
<!-- Default slot for direct composition -->
<slot />
<!-- Props-based usage for simple cases -->
<template v-if="items && items.length > 0">
<AccordionItem
v-for="item in items"
:key="item.value"
:value="item.value"
:disabled="item.disabled"
>
<AccordionTrigger>
<slot name="trigger" :item="item">
{{ item.title }}
</slot>
</AccordionTrigger>
<AccordionContent>
<slot name="content" :item="item">
{{ item.content }}
</slot>
</AccordionContent>
</AccordionItem>
</template>
</AccordionRoot>
</template>

View File

@@ -1,4 +1,2 @@
export { default as Accordion } from './Accordion.vue';
export { default as AccordionContent } from './AccordionContent.vue';
export { default as AccordionItem } from './AccordionItem.vue';
export { default as AccordionTrigger } from './AccordionTrigger.vue';
export type { AccordionItemData, AccordionProps } from './Accordion.vue';

View File

@@ -1,15 +1,109 @@
<script setup lang="ts">
import { DialogRoot, useForwardPropsEmits } from 'reka-ui';
import type { DialogRootEmits, DialogRootProps } from 'reka-ui';
import Button from '@/components/common/button/Button.vue';
import {
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogRoot,
DialogScrollContent,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
const props = defineProps<DialogRootProps>();
const emits = defineEmits<DialogRootEmits>();
export interface DialogProps {
description?: string;
title?: string;
triggerText?: string;
modelValue?: boolean;
showFooter?: boolean;
closeButtonText?: string;
primaryButtonText?: string;
primaryButtonLoading?: boolean;
primaryButtonLoadingText?: string;
primaryButtonDisabled?: boolean;
scrollable?: boolean;
size?: 'sm' | 'md' | 'lg' | 'xl';
}
const forwarded = useForwardPropsEmits(props, emits);
const {
description,
title,
triggerText,
modelValue,
showFooter = true,
closeButtonText = 'Close',
primaryButtonText,
primaryButtonLoading = false,
primaryButtonLoadingText,
primaryButtonDisabled = false,
scrollable = false,
size = 'md',
} = defineProps<DialogProps>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
'primary-click': [];
}>();
const handleOpenChange = (open: boolean) => {
emit('update:modelValue', open);
};
const handlePrimaryClick = () => {
emit('primary-click');
};
const sizeClasses = {
sm: 'max-w-sm',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
};
</script>
<template>
<DialogRoot v-bind="forwarded">
<slot />
<DialogRoot :open="modelValue" @update:open="handleOpenChange">
<DialogTrigger v-if="triggerText || $slots.trigger">
<slot name="trigger">
<Button>{{ triggerText }}</Button>
</slot>
</DialogTrigger>
<component :is="scrollable ? DialogScrollContent : DialogContent" :class="cn(sizeClasses[size])">
<DialogHeader v-if="title || description || $slots.header">
<slot name="header">
<DialogTitle v-if="title">{{ title }}</DialogTitle>
<DialogDescription v-if="description">
{{ description }}
</DialogDescription>
</slot>
</DialogHeader>
<slot />
<DialogFooter v-if="$slots.footer || showFooter">
<slot name="footer">
<div class="flex justify-end gap-2">
<DialogClose as-child>
<Button variant="secondary">{{ closeButtonText }}</Button>
</DialogClose>
<Button
v-if="primaryButtonText"
variant="primary"
:disabled="primaryButtonDisabled || primaryButtonLoading"
@click="handlePrimaryClick"
>
<span v-if="primaryButtonLoading && primaryButtonLoadingText">
{{ primaryButtonLoadingText }}
</span>
<span v-else>{{ primaryButtonText }}</span>
</Button>
</div>
</slot>
</DialogFooter>
</component>
</DialogRoot>
</template>

View File

@@ -1,14 +0,0 @@
<script setup lang="ts">
import { DialogClose, useForwardPropsEmits, type DialogCloseProps } from 'reka-ui';
const props = defineProps<DialogCloseProps>();
const emits = defineEmits([]);
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<DialogClose v-bind="forwarded">
<slot />
</DialogClose>
</template>

View File

@@ -1,9 +1,4 @@
export { default as Dialog } from './Dialog.vue';
export { default as DialogClose } from './DialogClose.vue';
export { default as DialogContent } from './DialogContent.vue';
export { default as DialogDescription } from './DialogDescription.vue';
export { default as DialogFooter } from './DialogFooter.vue';
export { default as DialogHeader } from './DialogHeader.vue';
export { default as DialogScrollContent } from './DialogScrollContent.vue';
export { default as DialogTitle } from './DialogTitle.vue';
export { default as DialogTrigger } from './DialogTrigger.vue';
// Type exports
export type { DialogProps } from './Dialog.vue';

View File

@@ -1,19 +1,75 @@
<script setup lang="ts">
import { Button } from '@/components/common/button';
import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRoot,
useForwardPropsEmits,
type DropdownMenuRootEmits,
type DropdownMenuRootProps,
} from 'reka-ui';
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
const props = defineProps<DropdownMenuRootProps>();
const emits = defineEmits<DropdownMenuRootEmits>();
export interface DropdownMenuItemData {
type?: 'item' | 'label' | 'separator';
label?: string;
disabled?: boolean;
onClick?: () => void;
}
const forwarded = useForwardPropsEmits<DropdownMenuRootProps, 'update:open'>(props, emits);
export interface DropdownMenuProps {
items?: DropdownMenuItemData[];
trigger?: string;
align?: 'start' | 'center' | 'end';
side?: 'top' | 'right' | 'bottom' | 'left';
sideOffset?: number;
}
const props = withDefaults(defineProps<DropdownMenuProps>(), {
align: 'start',
side: 'bottom',
sideOffset: 4,
});
const emit = defineEmits<{
select: [item: DropdownMenuItemData];
}>();
function handleItemClick(item: DropdownMenuItemData) {
item.onClick?.();
emit('select', item);
}
</script>
<template>
<DropdownMenuRoot v-bind="forwarded">
<slot />
<DropdownMenuRoot>
<DropdownMenuTrigger v-if="!$slots.trigger" as-child>
<Button variant="primary">
{{ props.trigger || 'Options' }}
</Button>
</DropdownMenuTrigger>
<DropdownMenuTrigger v-else as-child>
<slot name="trigger" />
</DropdownMenuTrigger>
<DropdownMenuContent :align="props.align" :side="props.side" :side-offset="props.sideOffset">
<!--Slot for direct composition -->
<slot name="content" />
<!-- Props-based items rendering -->
<template v-if="props.items && props.items.length > 0">
<template v-for="item in props.items" :key="item.label || item.type">
<DropdownMenuSeparator v-if="item.type === 'separator'" />
<DropdownMenuLabel v-else-if="item.type === 'label'">
{{ item.label }}
</DropdownMenuLabel>
<DropdownMenuItem v-else :disabled="item.disabled" @click="handleItemClick(item)">
{{ item.label }}
</DropdownMenuItem>
</template>
</template>
</DropdownMenuContent>
</DropdownMenuRoot>
</template>

View File

@@ -1,30 +0,0 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import {
DropdownMenuArrow as RekaDropdownMenuArrow,
useForwardProps,
type DropdownMenuArrowProps,
} from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
const props = defineProps<DropdownMenuArrowProps & { class?: HTMLAttributes['class'] }>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<RekaDropdownMenuArrow
v-bind="forwardedProps"
:class="
cn(
'fill-popover 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',
props.class
)
"
/>
</template>

View File

@@ -1,18 +1,2 @@
import DropdownMenu from '@/components/common/dropdown-menu/DropdownMenu.vue';
export { DropdownMenu };
export { default as DropdownMenuCheckboxItem } from './DropdownMenuCheckboxItem.vue';
export { default as DropdownMenuContent } from './DropdownMenuContent.vue';
export { default as DropdownMenuGroup } from './DropdownMenuGroup.vue';
export { default as DropdownMenuItem } from './DropdownMenuItem.vue';
export { default as DropdownMenuLabel } from './DropdownMenuLabel.vue';
export { default as DropdownMenuRadioGroup } from './DropdownMenuRadioGroup.vue';
export { default as DropdownMenuRadioItem } from './DropdownMenuRadioItem.vue';
export { default as DropdownMenuSeparator } from './DropdownMenuSeparator.vue';
export { default as DropdownMenuShortcut } from './DropdownMenuShortcut.vue';
export { default as DropdownMenuSub } from './DropdownMenuSub.vue';
export { default as DropdownMenuSubContent } from './DropdownMenuSubContent.vue';
export { default as DropdownMenuSubTrigger } from './DropdownMenuSubTrigger.vue';
export { default as DropdownMenuTrigger } from './DropdownMenuTrigger.vue';
export { default as DropdownMenuArrow } from './DropdownMenuArrow.vue';
export { DropdownMenuPortal } from 'reka-ui';
export { default as DropdownMenu } from './DropdownMenu.vue';
export type { DropdownMenuItemData, DropdownMenuProps } from './DropdownMenu.vue';

View File

@@ -1,15 +1,202 @@
<script setup lang="ts">
import type { SelectRootEmits, SelectRootProps } from 'reka-ui';
import { SelectRoot, useForwardPropsEmits } from 'reka-ui';
import {
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectRoot,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { computed } from 'vue';
const props = defineProps<SelectRootProps>();
const emits = defineEmits<SelectRootEmits>();
type SelectValueType = string | number;
const forwarded = useForwardPropsEmits<SelectRootProps, 'update:modelValue'>(props, emits);
type AcceptableValue = SelectValueType | SelectValueType[] | Record<string, unknown> | null;
interface SelectItemInterface {
label: string;
value: SelectValueType;
disabled?: boolean;
class?: string;
[key: string]: unknown;
}
interface SelectLabelInterface {
type: 'label';
label: string;
}
interface SelectSeparatorInterface {
type: 'separator';
}
// Union type for all possible items
export type SelectItemType =
| SelectItemInterface
| SelectLabelInterface
| SelectSeparatorInterface
| SelectValueType;
export interface SelectProps {
modelValue?: AcceptableValue;
items?: SelectItemType[] | SelectItemType[][];
placeholder?: string;
multiple?: boolean;
disabled?: boolean;
required?: boolean;
name?: string;
class?: string;
valueKey?: string;
labelKey?: string;
}
const props = withDefaults(defineProps<SelectProps>(), {
items: () => [],
multiple: false,
valueKey: 'value',
labelKey: 'label',
});
const emit = defineEmits<{
'update:modelValue': [value: AcceptableValue];
}>();
function isStructuredItem(item: SelectItemType): item is SelectItemInterface {
return typeof item === 'object' && item !== null && 'value' in item;
}
function isLabelItem(item: SelectItemType): item is SelectLabelInterface {
return typeof item === 'object' && item !== null && 'type' in item && item.type === 'label';
}
function isSeparatorItem(item: SelectItemType): item is SelectSeparatorInterface {
return typeof item === 'object' && item !== null && 'type' in item && item.type === 'separator';
}
function getItemLabel(item: SelectItemType): string {
if (isStructuredItem(item)) {
return String(props.labelKey in item ? item[props.labelKey] : (item.label ?? item.value));
}
if (isLabelItem(item)) return item.label;
return String(item);
}
// Get value for an item
function getItemValue(item: SelectItemType): SelectValueType | null {
if (isStructuredItem(item)) {
const value = props.valueKey in item ? item[props.valueKey] : item.value;
return typeof value === 'string' || typeof value === 'number' ? value : null;
}
if (isLabelItem(item) || isSeparatorItem(item)) return null;
return item;
}
function isGroupedItems(items: SelectItemType[] | SelectItemType[][]): items is SelectItemType[][] {
return Array.isArray(items) && items.length > 0 && Array.isArray(items[0]);
}
const itemGroups = computed(() => {
if (!props.items) return [];
return isGroupedItems(props.items) ? props.items : [props.items];
});
const flatItems = computed(() => {
return itemGroups.value.flat();
});
const renderableItems = computed(() => {
return flatItems.value.filter((item) => !isLabelItem(item) && !isSeparatorItem(item));
});
const groupedOrderedItems = computed(() => {
return itemGroups.value.map((group, groupIndex) => ({
groupIndex,
items: group.map((item, index) => ({
item,
index,
type: isLabelItem(item) ? 'label' : isSeparatorItem(item) ? 'separator' : 'item',
})),
}));
});
const isMultipleSelection = computed(() => {
return props.multiple && Array.isArray(props.modelValue) && props.modelValue.length > 0;
});
const multipleValueDisplay = computed(() => {
if (!isMultipleSelection.value || !Array.isArray(props.modelValue)) return '';
const values = props.modelValue as SelectValueType[];
const displayLabels = values.map((value) => {
const item = renderableItems.value.find((item) => {
const itemValue = getItemValue(item);
return itemValue === value;
});
return item ? getItemLabel(item) : String(value);
});
if (displayLabels.length <= 2) {
return displayLabels.join(', ');
} else {
return `${displayLabels[0]}, ${displayLabels[1]} +${displayLabels.length - 2} more`;
}
});
function handleUpdateModelValue(value: AcceptableValue) {
emit('update:modelValue', value);
}
</script>
<template>
<SelectRoot v-bind="forwarded">
<slot />
<SelectRoot
:model-value="props.modelValue"
:multiple="props.multiple"
:disabled="props.disabled"
:required="props.required"
:name="props.name"
@update:model-value="handleUpdateModelValue"
>
<SelectTrigger :class="props.class">
<slot>
<SelectValue :placeholder="props.placeholder">
<template v-if="isMultipleSelection">
{{ multipleValueDisplay }}
</template>
</SelectValue>
</slot>
</SelectTrigger>
<SelectContent>
<slot name="content-top" />
<SelectGroup v-for="{ groupIndex, items } in groupedOrderedItems" :key="groupIndex">
<template v-for="{ item, index, type } in items" :key="index">
<SelectLabel v-if="type === 'label'">
{{ getItemLabel(item) }}
</SelectLabel>
<SelectSeparator v-if="type === 'separator'" />
<SelectItem
v-if="type === 'item'"
:value="getItemValue(item)!"
:disabled="isStructuredItem(item) ? item.disabled : false"
:class="isStructuredItem(item) ? item.class : undefined"
>
<slot name="item" :item="item" :index="index">
{{ getItemLabel(item) }}
</slot>
</SelectItem>
</template>
</SelectGroup>
<slot name="content-bottom" />
</SelectContent>
</SelectRoot>
</template>

View File

@@ -1,11 +1,2 @@
export { default as Select } from './Select.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 SelectScrollDownButton } from './SelectScrollDownButton.vue';
export { default as SelectScrollUpButton } from './SelectScrollUpButton.vue';
export { default as SelectSeparator } from './SelectSeparator.vue';
export { default as SelectTrigger } from './SelectTrigger.vue';
export { default as SelectValue } from './SelectValue.vue';
export type { SelectItemType, SelectProps } from './Select.vue';

View File

@@ -12,7 +12,7 @@ const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<AccordionItem v-bind="forwardedProps" :class="cn(props.class)">
<AccordionItem v-bind="forwardedProps" :class="cn('border-b', props.class)">
<slot />
</AccordionItem>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import {
AccordionRoot,
useForwardPropsEmits,
type AccordionRootEmits,
type AccordionRootProps,
} from 'reka-ui';
const props = defineProps<AccordionRootProps>();
const emits = defineEmits<AccordionRootEmits>();
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<AccordionRoot v-bind="forwarded">
<slot />
</AccordionRoot>
</template>

View File

@@ -16,7 +16,7 @@ const delegatedProps = reactiveOmit(props, 'class');
v-bind="delegatedProps"
:class="
cn(
'flex flex-1 items-center justify-between p-2 rounded-md font-medium transition-all border border-border hover:border-muted-foreground focus:border-muted-foreground [&[data-state=open]>svg]:rotate-180',
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
props.class
)
"

View File

@@ -0,0 +1,4 @@
export { default as AccordionRoot } from './AccordionRoot.vue';
export { default as AccordionContent } from './AccordionContent.vue';
export { default as AccordionItem } from './AccordionItem.vue';
export { default as AccordionTrigger } from './AccordionTrigger.vue';

View File

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

View File

@@ -33,7 +33,7 @@ const { teleportTarget } = useTeleport();
v-bind="forwarded"
:class="
cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg rounded-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
props.class
)
"

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import { DialogRoot, useForwardPropsEmits, type DialogRootEmits, type DialogRootProps } from 'reka-ui';
const props = defineProps<DialogRootProps>();
const emits = defineEmits<DialogRootEmits>();
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<DialogRoot v-bind="forwarded">
<slot />
</DialogRoot>
</template>

View File

@@ -14,16 +14,17 @@ import {
} from 'reka-ui';
import type { HTMLAttributes } from 'vue';
const { teleportTarget } = useTeleport();
const props = defineProps<
DialogContentProps & { class?: HTMLAttributes['class'] } & { to?: string | HTMLElement }
>();
const emits = defineEmits<DialogContentEmits>();
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardPropsEmits(delegatedProps, emits);
const { teleportTarget } = useTeleport();
</script>
<template>

View File

@@ -0,0 +1,9 @@
export { default as DialogRoot } from './DialogRoot.vue';
export { default as DialogClose } from './DialogClose.vue';
export { default as DialogContent } from './DialogContent.vue';
export { default as DialogDescription } from './DialogDescription.vue';
export { default as DialogFooter } from './DialogFooter.vue';
export { default as DialogHeader } from './DialogHeader.vue';
export { default as DialogScrollContent } from './DialogScrollContent.vue';
export { default as DialogTitle } from './DialogTitle.vue';
export { default as DialogTrigger } from './DialogTrigger.vue';

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
import { Check } from 'lucide-vue-next';
import {
DropdownMenuCheckboxItem,
@@ -8,16 +9,12 @@ import {
type DropdownMenuCheckboxItemEmits,
type DropdownMenuCheckboxItemProps,
} from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
import type { HTMLAttributes } from 'vue';
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes['class'] }>();
const emits = defineEmits<DropdownMenuCheckboxItemEmits>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import useTeleport from '@/composables/useTeleport';
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
import {
DropdownMenuContent,
DropdownMenuPortal,
@@ -8,9 +9,7 @@ import {
type DropdownMenuContentEmits,
type DropdownMenuContentProps,
} from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
const { teleportTarget } = useTeleport();
import type { HTMLAttributes } from 'vue';
const props = withDefaults(
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes['class'] }>(),
@@ -20,29 +19,25 @@ const props = withDefaults(
);
const emits = defineEmits<DropdownMenuContentEmits>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardPropsEmits(delegatedProps, emits);
const { teleportTarget } = useTeleport();
</script>
<template>
<DropdownMenuPortal :to="teleportTarget">
<DropdownMenuContent
v-bind="forwarded"
side="bottom"
:class="
cn(
'z-50 min-w-32 rounded-lg 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',
'z-50 min-w-32 overflow-hidden rounded-lg 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
)
"
>
<div class="overflow-hidden">
<slot />
</div>
<slot />
</DropdownMenuContent>
</DropdownMenuPortal>
</template>

View File

@@ -1,17 +1,14 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
import { DropdownMenuItem, useForwardProps, type DropdownMenuItemProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
import type { HTMLAttributes } from 'vue';
const props = defineProps<
DropdownMenuItemProps & { class?: HTMLAttributes['class']; inset?: boolean }
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const delegatedProps = reactiveOmit(props, 'class');
const forwardedProps = useForwardProps(delegatedProps);
</script>

View File

@@ -1,17 +1,14 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
import { DropdownMenuLabel, useForwardProps, type DropdownMenuLabelProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
import type { HTMLAttributes } from 'vue';
const props = defineProps<
DropdownMenuLabelProps & { class?: HTMLAttributes['class']; inset?: boolean }
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const delegatedProps = reactiveOmit(props, 'class');
const forwardedProps = useForwardProps(delegatedProps);
</script>

View File

@@ -9,7 +9,7 @@ import {
const props = defineProps<DropdownMenuRadioGroupProps>();
const emits = defineEmits<DropdownMenuRadioGroupEmits>();
const forwarded = useForwardPropsEmits<DropdownMenuRadioGroupProps, 'update:modelValue'>(props, emits);
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
import { Circle } from 'lucide-vue-next';
import {
DropdownMenuItemIndicator,
@@ -8,17 +9,13 @@ import {
type DropdownMenuRadioItemEmits,
type DropdownMenuRadioItemProps,
} from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
import type { HTMLAttributes } from 'vue';
const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes['class'] }>();
const emits = defineEmits<DropdownMenuRadioItemEmits>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>

View File

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

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
import { DropdownMenuSeparator, type DropdownMenuSeparatorProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
import type { HTMLAttributes } from 'vue';
const props = defineProps<
DropdownMenuSeparatorProps & {
@@ -9,11 +10,7 @@ const props = defineProps<
}
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const delegatedProps = reactiveOmit(props, 'class');
</script>
<template>

View File

@@ -9,7 +9,7 @@ import {
const props = defineProps<DropdownMenuSubProps>();
const emits = defineEmits<DropdownMenuSubEmits>();
const forwarded = useForwardPropsEmits<DropdownMenuSubProps, 'update:open'>(props, emits);
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>

View File

@@ -1,21 +1,18 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
import {
DropdownMenuSubContent,
useForwardPropsEmits,
type DropdownMenuSubContentEmits,
type DropdownMenuSubContentProps,
} from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
import type { HTMLAttributes } from 'vue';
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes['class'] }>();
const emits = defineEmits<DropdownMenuSubContentEmits>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>

View File

@@ -1,16 +1,13 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
import { ChevronRight } from 'lucide-vue-next';
import { DropdownMenuSubTrigger, useForwardProps, type DropdownMenuSubTriggerProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
import type { HTMLAttributes } from 'vue';
const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes['class'] }>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const delegatedProps = reactiveOmit(props, 'class');
const forwardedProps = useForwardProps(delegatedProps);
</script>

View File

@@ -3,11 +3,14 @@ import { DropdownMenuTrigger, useForwardProps, type DropdownMenuTriggerProps } f
const props = defineProps<DropdownMenuTriggerProps>();
const forwardedProps = useForwardProps<DropdownMenuTriggerProps>(props);
const forwardedProps = useForwardProps(props);
</script>
<template>
<DropdownMenuTrigger class="outline-none" v-bind="forwardedProps">
<DropdownMenuTrigger
class="outline-none cursor-pointer [&[data-state=open]]:cursor-pointer"
v-bind="forwardedProps"
>
<slot />
</DropdownMenuTrigger>
</template>

View File

@@ -0,0 +1,15 @@
export { default as DropdownMenuRoot } from './DropdownMenuRoot.vue';
export { default as DropdownMenuCheckboxItem } from './DropdownMenuCheckboxItem.vue';
export { default as DropdownMenuContent } from './DropdownMenuContent.vue';
export { default as DropdownMenuGroup } from './DropdownMenuGroup.vue';
export { default as DropdownMenuItem } from './DropdownMenuItem.vue';
export { default as DropdownMenuLabel } from './DropdownMenuLabel.vue';
export { default as DropdownMenuRadioGroup } from './DropdownMenuRadioGroup.vue';
export { default as DropdownMenuRadioItem } from './DropdownMenuRadioItem.vue';
export { default as DropdownMenuSeparator } from './DropdownMenuSeparator.vue';
export { default as DropdownMenuShortcut } from './DropdownMenuShortcut.vue';
export { default as DropdownMenuSub } from './DropdownMenuSub.vue';
export { default as DropdownMenuSubContent } from './DropdownMenuSubContent.vue';
export { default as DropdownMenuSubTrigger } from './DropdownMenuSubTrigger.vue';
export { default as DropdownMenuTrigger } from './DropdownMenuTrigger.vue';
export { DropdownMenuPortal } from 'reka-ui';

View File

@@ -0,0 +1,5 @@
// Export all UI primitives
export * from './accordion';
export * from './dialog';
export * from './dropdown-menu';
export * from './select';

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import useTeleport from '@/composables/useTeleport';
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
import {
SelectContent,
SelectPortal,
@@ -9,34 +10,27 @@ import {
type SelectContentEmits,
type SelectContentProps,
} from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
import type { HTMLAttributes } from 'vue';
import { SelectScrollDownButton, SelectScrollUpButton } from '.';
defineOptions({
inheritAttrs: false,
});
const { teleportTarget } = useTeleport();
const props = withDefaults(defineProps<SelectContentProps & { class?: HTMLAttributes['class'] }>(), {
forceMount: false,
position: 'popper',
to: undefined,
});
const emits = defineEmits<SelectContentEmits>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardPropsEmits(delegatedProps, emits);
const { teleportTarget } = useTeleport();
</script>
<template>
<SelectPortal :force-mount="forceMount" :to="teleportTarget">
<SelectPortal :to="teleportTarget">
<SelectContent
v-bind="{ ...forwarded, ...$attrs }"
:class="

View File

@@ -1,15 +1,12 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
import { SelectGroup, type SelectGroupProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
import type { HTMLAttributes } from 'vue';
const props = defineProps<SelectGroupProps & { class?: HTMLAttributes['class'] }>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const delegatedProps = reactiveOmit(props, 'class');
</script>
<template>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
import { Check } from 'lucide-vue-next';
import {
SelectItem,
@@ -8,15 +9,11 @@ import {
useForwardProps,
type SelectItemProps,
} from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
import type { HTMLAttributes } from 'vue';
const props = defineProps<SelectItemProps & { class?: HTMLAttributes['class'] }>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const delegatedProps = reactiveOmit(props, 'class');
const forwardedProps = useForwardProps(delegatedProps);
</script>

View File

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

View File

@@ -1,16 +1,13 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
import { ChevronDown } from 'lucide-vue-next';
import { SelectScrollDownButton, useForwardProps, type SelectScrollDownButtonProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
import type { HTMLAttributes } from 'vue';
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes['class'] }>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const delegatedProps = reactiveOmit(props, 'class');
const forwardedProps = useForwardProps(delegatedProps);
</script>

View File

@@ -1,16 +1,13 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
import { ChevronUp } from 'lucide-vue-next';
import { SelectScrollUpButton, useForwardProps, type SelectScrollUpButtonProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
import type { HTMLAttributes } from 'vue';
const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes['class'] }>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const delegatedProps = reactiveOmit(props, 'class');
const forwardedProps = useForwardProps(delegatedProps);
</script>

View File

@@ -1,15 +1,12 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
import { SelectSeparator, type SelectSeparatorProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
import type { HTMLAttributes } from 'vue';
const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes['class'] }>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const delegatedProps = reactiveOmit(props, 'class');
</script>
<template>

View File

@@ -1,16 +1,13 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
import { ChevronDown } from 'lucide-vue-next';
import { SelectIcon, SelectTrigger, useForwardProps, type SelectTriggerProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
import type { HTMLAttributes } from 'vue';
const props = defineProps<SelectTriggerProps & { class?: HTMLAttributes['class'] }>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const delegatedProps = reactiveOmit(props, 'class');
const forwardedProps = useForwardProps(delegatedProps);
</script>

View File

@@ -0,0 +1,11 @@
export { default as SelectRoot } from './SelectRoot.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 SelectScrollDownButton } from './SelectScrollDownButton.vue';
export { default as SelectScrollUpButton } from './SelectScrollUpButton.vue';
export { default as SelectSeparator } from './SelectSeparator.vue';
export { default as SelectTrigger } from './SelectTrigger.vue';
export { default as SelectValue } from './SelectValue.vue';

View File

@@ -1,13 +1,13 @@
<script setup lang="ts">
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/common/tooltip';
import {
Select,
SelectContent,
SelectItem,
SelectItemText,
SelectRoot,
SelectTrigger,
SelectValue,
} from '@/components/form/select';
} from '@/components/ui/select';
import useTeleport from '@/composables/useTeleport';
import type { ControlElement } from '@jsonforms/core';
import { useJsonFormsControl } from '@jsonforms/vue';
@@ -41,20 +41,18 @@ const onSelectOpen = () => {
</script>
<template>
<!-- The ControlWrapper now handles the v-if based on control.visible -->
<Select
<SelectRoot
v-model="selected"
:disabled="!control.enabled"
:required="control.required"
@update:model-value="onChange"
@update:open="onSelectOpen"
>
<!-- The trigger shows the currently selected value (if any) -->
<SelectTrigger>
<SelectValue v-if="selected">{{ selected }}</SelectValue>
<span v-else>{{ control.schema.default ?? 'Select an option' }}</span>
</SelectTrigger>
<!-- The content includes the selectable options -->
<SelectContent :to="teleportTarget">
<template v-for="option in options" :key="option.value">
<TooltipProvider v-if="option.tooltip" :delay-duration="50">
@@ -69,10 +67,11 @@ const onSelectOpen = () => {
</TooltipContent>
</Tooltip>
</TooltipProvider>
<SelectItem v-else :value="option.value">
<SelectItemText>{{ option.label }}</SelectItemText>
</SelectItem>
</template>
</SelectContent>
</Select>
</SelectRoot>
</template>

View File

@@ -4,6 +4,9 @@ import '@/styles/index.css';
// Components
export * from '@/components';
// Component Primitives
export * from '@/components/ui';
// JsonForms
export * from '@/forms/renderers';

View File

@@ -0,0 +1,365 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import { Accordion } from '../../../src/components/common/accordion';
import { AccordionContent, AccordionItem, AccordionTrigger } from '../../../src/components/ui/accordion';
const meta = {
title: 'Components/Common/Accordion',
component: Accordion,
argTypes: {
type: {
control: { type: 'select' },
options: ['single', 'multiple'],
},
collapsible: {
control: { type: 'boolean' },
},
},
} satisfies Meta<typeof Accordion>;
export default meta;
type Story = StoryObj<typeof meta>;
// Basic usage with items prop
export const BasicUsage: Story = {
args: {
type: 'single',
collapsible: true,
items: [
{
value: 'item-1',
title: 'Is it accessible?',
content: 'Yes. It adheres to the WAI-ARIA design pattern.',
},
{
value: 'item-2',
title: 'Is it styled?',
content: "Yes. It comes with default styles that matches the other components' aesthetic.",
},
{
value: 'item-3',
title: 'Is it animated?',
content: "Yes. It's animated by default, but you can disable it if you prefer.",
},
],
},
render: (args) => ({
components: { Accordion },
setup() {
return { args };
},
template: `
<Accordion
:type="args.type"
:collapsible="args.collapsible"
:items="args.items"
:class="args.class"
/>
`,
}),
};
// Items with custom slot content
export const ItemsWithCustomSlots: Story = {
args: {
type: 'single',
collapsible: true,
items: [
{
value: 'stats',
title: 'User Statistics',
content: 'View your usage statistics',
},
{
value: 'settings',
title: 'Account Settings',
content: 'Manage your account preferences',
},
{
value: 'notifications',
title: 'Notifications',
content: 'Configure notification preferences',
},
],
},
render: (args) => ({
components: { Accordion },
setup() {
return { args };
},
template: `
<Accordion
:type="args.type"
:collapsible="args.collapsible"
:items="args.items"
>
<template #trigger="{ item }">
<div style="display: flex; align-items: center; justify-content: space-between; width: 100%;">
<span style="font-weight: 500;">{{ item.title }}</span>
<span style="background: #e0e0e0; padding: 2px 8px; border-radius: 12px; font-size: 12px;">
Custom
</span>
</div>
</template>
<template #content="{ item }">
<div style="padding: 16px; background-color: #f8f8f8; border-radius: 4px;">
<p style="margin: 0 0 12px 0;">{{ item.content }}</p>
<button style="background: #007bff; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer;">
Manage {{ item.title }}
</button>
</div>
</template>
</Accordion>
`,
}),
};
// Direct composition pattern
export const DirectComposition: Story = {
render: () => ({
components: { Accordion, AccordionItem, AccordionTrigger, AccordionContent },
template: `
<div>
<h3 style="margin-bottom: 16px;">Direct Composition Pattern</h3>
<Accordion type="single" collapsible>
<AccordionItem value="permissions">
<AccordionTrigger>
<div style="display: flex; align-items: center; gap: 8px;">
<span>🔒</span>
<span>Permissions Management</span>
</div>
</AccordionTrigger>
<AccordionContent>
<div style="padding: 16px;">
<p style="margin: 0 0 12px 0;">Manage user permissions and access control</p>
<div style="display: flex; gap: 8px;">
<label><input type="checkbox" /> Read</label>
<label><input type="checkbox" /> Write</label>
<label><input type="checkbox" /> Delete</label>
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="api-keys">
<AccordionTrigger>
<div style="display: flex; align-items: center; gap: 8px;">
<span>🔑</span>
<span>API Keys</span>
</div>
</AccordionTrigger>
<AccordionContent>
<div style="padding: 16px;">
<p style="margin: 0 0 12px 0;">Create and manage API keys</p>
<button style="background: #28a745; color: white; border: none; padding: 8px 16px; border-radius: 4px;">
Generate New Key
</button>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="webhooks">
<AccordionTrigger>
<div style="display: flex; align-items: center; gap: 8px;">
<span>🪝</span>
<span>Webhooks</span>
</div>
</AccordionTrigger>
<AccordionContent>
<div style="padding: 16px;">
<p style="margin: 0 0 12px 0;">Configure webhook endpoints</p>
<input type="text" placeholder="Webhook URL" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;" />
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
`,
}),
};
// Multiple mode with default values
export const MultipleMode: Story = {
args: {
type: 'multiple',
defaultValue: ['item-1', 'item-3'],
items: [
{
value: 'item-1',
title: 'First Section (Default Open)',
content: 'This section is open by default in multiple mode.',
},
{
value: 'item-2',
title: 'Second Section',
content: 'Multiple sections can be open at the same time.',
},
{
value: 'item-3',
title: 'Third Section (Default Open)',
content: 'This section is also open by default.',
},
],
},
render: (args) => ({
components: { Accordion },
setup() {
return { args };
},
template: `
<Accordion
:type="args.type"
:default-value="args.defaultValue"
:items="args.items"
/>
`,
}),
};
// Mixed usage pattern
export const MixedUsage: Story = {
args: {
items: [
{
value: 'auto-1',
title: 'Auto-generated Item 1',
content: 'This item comes from the items prop',
},
{
value: 'auto-2',
title: 'Auto-generated Item 2',
content: 'This item also comes from the items prop',
},
],
},
render: (args) => ({
components: { Accordion, AccordionItem, AccordionTrigger, AccordionContent },
setup() {
return { args };
},
template: `
<div>
<h3 style="margin-bottom: 16px;">Mixed Usage (Direct + Items)</h3>
<Accordion type="single" collapsible :items="args.items">
<!-- Direct composition items render first -->
<AccordionItem value="manual-1">
<AccordionTrigger>
<span style="color: #007bff;">📝 Manually Added Item</span>
</AccordionTrigger>
<AccordionContent>
<div style="padding: 16px; background: #e3f2fd; border-radius: 4px;">
This item was added using direct composition and appears before items from props.
</div>
</AccordionContent>
</AccordionItem>
<!-- Items from props will render after -->
</Accordion>
</div>
`,
}),
};
// Dynamic component pattern
export const DynamicComponents: Story = {
render: () => ({
components: { Accordion },
setup() {
// Mock components
const PermissionsPanel = {
template: `
<div style="padding: 16px; background: #f0f8ff; border-radius: 4px;">
<h4 style="margin: 0 0 8px 0;">Permission Settings</h4>
<label style="display: block; margin: 4px 0;"><input type="checkbox" checked /> Admin Access</label>
<label style="display: block; margin: 4px 0;"><input type="checkbox" /> Editor Access</label>
<label style="display: block; margin: 4px 0;"><input type="checkbox" checked /> Viewer Access</label>
</div>
`,
};
const ProfilePanel = {
template: `
<div style="padding: 16px; background: #f0fff0; border-radius: 4px;">
<h4 style="margin: 0 0 8px 0;">User Profile</h4>
<input type="text" value="John Doe" style="width: 100%; padding: 8px; margin: 4px 0; border: 1px solid #ddd; border-radius: 4px;" />
<input type="email" value="john@example.com" style="width: 100%; padding: 8px; margin: 4px 0; border: 1px solid #ddd; border-radius: 4px;" />
</div>
`,
};
const SettingsPanel = {
template: `
<div style="padding: 16px; background: #fff0f0; border-radius: 4px;">
<h4 style="margin: 0 0 8px 0;">Application Settings</h4>
<label style="display: block; margin: 4px 0;"><input type="checkbox" checked /> Enable notifications</label>
<label style="display: block; margin: 4px 0;"><input type="checkbox" /> Dark mode</label>
<label style="display: block; margin: 4px 0;"><input type="checkbox" checked /> Auto-save</label>
</div>
`,
};
const componentMap = {
permissions: PermissionsPanel,
profile: ProfilePanel,
settings: SettingsPanel,
};
const items = [
{ value: 'item-1', title: 'Permissions', componentType: 'permissions' },
{ value: 'item-2', title: 'Profile', componentType: 'profile' },
{ value: 'item-3', title: 'Settings', componentType: 'settings' },
];
return { items, componentMap };
},
template: `
<div>
<h3 style="margin-bottom: 16px;">Dynamic Components Pattern</h3>
<Accordion :items="items">
<template #content="{ item }">
<component :is="componentMap[item.componentType]" />
</template>
</Accordion>
</div>
`,
}),
};
// Disabled items
export const DisabledItems: Story = {
args: {
type: 'single',
collapsible: true,
items: [
{
value: 'item-1',
title: 'Enabled Item',
content: 'This item can be toggled.',
},
{
value: 'item-2',
title: 'Disabled Item',
content: 'This item cannot be toggled.',
disabled: true,
},
{
value: 'item-3',
title: 'Another Enabled Item',
content: 'This item can also be toggled.',
},
],
},
render: (args) => ({
components: { Accordion },
setup() {
return { args };
},
template: `
<Accordion
:type="args.type"
:collapsible="args.collapsible"
:items="args.items"
/>
`,
}),
};

View File

@@ -0,0 +1,266 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import { ref } from 'vue';
import Button from '../../../src/components/common/button/Button.vue';
import DialogComponent from '../../../src/components/common/dialog/Dialog.vue';
const meta = {
title: 'Components/Common/Dialog',
component: DialogComponent,
} satisfies Meta<typeof DialogComponent>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Dialog: Story = {
args: {
title: 'Dialog Title',
description: "This is a dialog description that explains what's happening.",
triggerText: 'Open Dialog',
showFooter: true,
closeButtonText: 'Close',
primaryButtonText: 'Save Changes',
},
render: (args) => ({
components: { DialogComponent },
setup() {
const isOpen = ref(false);
const handlePrimaryClick = () => {
console.log('Primary button clicked');
isOpen.value = false;
};
return { args, isOpen, handlePrimaryClick };
},
template: `
<DialogComponent
v-model="isOpen"
:title="args.title"
:description="args.description"
:triggerText="args.triggerText"
:showFooter="args.showFooter"
:closeButtonText="args.closeButtonText"
:primaryButtonText="args.primaryButtonText"
@primary-click="handlePrimaryClick"
/>
`,
}),
};
export const SimpleDialog: Story = {
args: {
title: 'Delete Item?',
description: 'This action cannot be undone.',
triggerText: 'Delete',
primaryButtonText: 'Confirm Delete',
},
};
export const DialogWithoutPrimaryButton: Story = {
args: {
title: 'Information',
description: 'This is just an informational dialog.',
triggerText: 'Show Info',
showFooter: true,
closeButtonText: 'Got it',
},
};
export const DialogWithCustomContent: Story = {
render: () => ({
components: { DialogComponent, Button },
setup() {
const isOpen = ref(false);
return { isOpen };
},
template: `
<DialogComponent v-model="isOpen" title="Custom Content Dialog">
<template #trigger>
<Button variant="secondary">Custom Trigger Button</Button>
</template>
<div style="padding: 20px 0;">
<p>This dialog has custom content in the body.</p>
<p>You can put any HTML or Vue components here.</p>
<div style="margin-top: 20px; padding: 20px; background: #f5f5f5; border-radius: 4px;">
<code>Custom content area</code>
</div>
</div>
</DialogComponent>
`,
}),
};
export const DialogWithCustomFooter: Story = {
render: () => ({
components: { DialogComponent, Button },
setup() {
const isOpen = ref(false);
const handleCancel = () => {
console.log('Cancel clicked');
isOpen.value = false;
};
const handleSave = () => {
console.log('Save clicked');
isOpen.value = false;
};
const handleSaveAndClose = () => {
console.log('Save and Close clicked');
isOpen.value = false;
};
return { isOpen, handleCancel, handleSave, handleSaveAndClose };
},
template: `
<DialogComponent v-model="isOpen" title="Custom Footer">
<template #trigger>
<Button>Open with Custom Footer</Button>
</template>
<p>This dialog has a completely custom footer with multiple actions.</p>
<template #footer>
<div style="display: flex; justify-content: space-between; width: 100%;">
<Button variant="ghost" @click="handleCancel">Cancel</Button>
<div style="display: flex; gap: 8px;">
<Button variant="secondary" @click="handleSave">Save</Button>
<Button variant="primary" @click="handleSaveAndClose">Save and Close</Button>
</div>
</div>
</template>
</DialogComponent>
`,
}),
};
export const DialogWithNoFooter: Story = {
render: () => ({
components: { DialogComponent },
setup() {
const isOpen = ref(false);
return { isOpen };
},
template: `
<DialogComponent
v-model="isOpen"
title="No Footer Dialog"
description="This dialog has no footer buttons."
triggerText="Open Dialog"
:showFooter="false"
>
<p style="padding: 20px 0;">Content without any footer buttons.</p>
</DialogComponent>
`,
}),
};
export const DialogControlledProgrammatically: Story = {
render: () => ({
components: { DialogComponent, Button },
setup() {
const isOpen = ref(false);
const openDialog = () => {
isOpen.value = true;
};
const closeDialog = () => {
isOpen.value = false;
};
return { isOpen, openDialog, closeDialog };
},
template: `
<div>
<Button @click="openDialog">Open Dialog Programmatically</Button>
<DialogComponent
v-model="isOpen"
title="Controlled Dialog"
description="This dialog is controlled programmatically without a trigger."
>
<p>This dialog was opened programmatically!</p>
<template #footer>
<Button @click="closeDialog">Close</Button>
</template>
</DialogComponent>
</div>
`,
}),
};
export const DialogSizes: Story = {
render: () => ({
components: { DialogComponent, Button },
setup() {
const dialogs = ref({
sm: false,
md: false,
lg: false,
xl: false,
});
return { dialogs };
},
template: `
<div style="display: flex; gap: 16px; flex-wrap: wrap;">
<DialogComponent
v-model="dialogs.sm"
size="sm"
title="Small Dialog"
description="This is a small dialog (max-width: 24rem)"
triggerText="Small (sm)"
primaryButtonText="Save"
>
<p>This dialog has a small size, perfect for simple confirmations or brief messages.</p>
</DialogComponent>
<DialogComponent
v-model="dialogs.md"
size="md"
title="Medium Dialog"
description="This is a medium dialog (max-width: 32rem)"
triggerText="Medium (md)"
primaryButtonText="Save"
>
<p>This is the default dialog size, suitable for most use cases with moderate content.</p>
</DialogComponent>
<DialogComponent
v-model="dialogs.lg"
size="lg"
title="Large Dialog"
description="This is a large dialog (max-width: 42rem)"
triggerText="Large (lg)"
primaryButtonText="Save"
>
<p>Large dialogs provide more space for complex forms or detailed content that requires more horizontal space.</p>
<div style="margin-top: 16px; padding: 16px; background: #f5f5f5; border-radius: 4px;">
<p>Additional content area to demonstrate the increased width.</p>
</div>
</DialogComponent>
<DialogComponent
v-model="dialogs.xl"
size="xl"
title="Extra Large Dialog"
description="This is an extra large dialog (max-width: 56rem)"
triggerText="Extra Large (xl)"
primaryButtonText="Save"
>
<p>Extra large dialogs are ideal for complex interfaces, data tables, or content that needs maximum horizontal space.</p>
<div style="margin-top: 16px;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="border-bottom: 1px solid #ddd;">
<th style="text-align: left; padding: 8px;">Column 1</th>
<th style="text-align: left; padding: 8px;">Column 2</th>
<th style="text-align: left; padding: 8px;">Column 3</th>
<th style="text-align: left; padding: 8px;">Column 4</th>
</tr>
</thead>
<tbody>
<tr style="border-bottom: 1px solid #eee;">
<td style="padding: 8px;">Data</td>
<td style="padding: 8px;">Data</td>
<td style="padding: 8px;">Data</td>
<td style="padding: 8px;">Data</td>
</tr>
</tbody>
</table>
</div>
</DialogComponent>
</div>
`,
}),
};

View File

@@ -1,24 +1,18 @@
import { DropdownMenu } from '@/components/common/dropdown-menu';
import type { Meta, StoryObj } from '@storybook/vue3';
import { MoreVertical } from 'lucide-vue-next';
import {
DropdownMenu,
DropdownMenuArrow,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/common/dropdown-menu';
import { Button } from '@/components/common/button';
const meta = {
title: 'Components/Common/DropdownMenu',
component: DropdownMenu,
tags: ['autodocs'],
argTypes: {
open: {
control: 'boolean',
description: 'Controls the open state of the dropdown menu',
align: {
control: { type: 'select' },
options: ['start', 'center', 'end'],
},
side: {
control: { type: 'select' },
options: ['top', 'right', 'bottom', 'left'],
},
},
} satisfies Meta<typeof DropdownMenu>;
@@ -27,66 +21,226 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const Dropdown: Story = {
render: () => ({
components: {
DropdownMenuArrow,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
Button,
// Basic props-based usage
export const BasicUsage: Story = {
args: {
trigger: 'Options',
items: [{ label: 'Profile' }, { label: 'Settings' }, { type: 'separator' }, { label: 'Logout' }],
},
render: (args) => ({
components: { DropdownMenu },
setup() {
return { args };
},
template: `
<div class="bg-gray-200 p-4 h-screen">
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="secondary">Open Menu</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>Profile</DropdownMenuItem>
<DropdownMenuItem>Settings</DropdownMenuItem>
<DropdownMenuItem>Logout</DropdownMenuItem>
<DropdownMenuArrow />
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu
:trigger="args.trigger"
:items="args.items"
:align="args.align"
:side="args.side"
:side-offset="args.sideOffset"
/>
`,
}),
};
// User account menu
export const UserAccountMenu: Story = {
args: {
trigger: 'John Doe',
items: [
{ type: 'label', label: 'john.doe@example.com' },
{ type: 'separator' },
{ label: 'Profile Settings' },
{ label: 'Account Security' },
{ label: 'Billing & Plans' },
{ type: 'separator' },
{ label: 'Help & Support' },
{ label: 'Keyboard Shortcuts', disabled: true },
{ type: 'separator' },
{ label: 'Sign Out' },
],
},
};
// File operations menu
export const FileOperationsMenu: Story = {
args: {
trigger: 'File Actions',
items: [
{ label: 'New File' },
{ label: 'New Folder' },
{ type: 'separator' },
{ label: 'Copy' },
{ label: 'Cut' },
{ label: 'Paste', disabled: true },
{ type: 'separator' },
{ label: 'Rename' },
{ label: 'Delete' },
{ type: 'separator' },
{ label: 'Properties' },
],
},
};
// Context menu style
export const ContextMenu: Story = {
args: {
trigger: 'Right Click Me',
align: 'start',
items: [
{ label: 'Back', disabled: true },
{ label: 'Forward', disabled: true },
{ label: 'Reload' },
{ type: 'separator' },
{ label: 'Save As...' },
{ label: 'Print...' },
{ label: 'Cast...' },
{ type: 'separator' },
{ label: 'View Page Source' },
{ label: 'Inspect' },
],
},
};
// Settings menu with grouped items
export const SettingsMenu: Story = {
args: {
trigger: '⚙️ Settings',
items: [
{ type: 'label', label: 'Appearance' },
{ label: 'Theme' },
{ label: 'Font Size' },
{ type: 'separator' },
{ type: 'label', label: 'Privacy' },
{ label: 'Clear Browsing Data' },
{ label: 'Cookie Settings' },
{ type: 'separator' },
{ type: 'label', label: 'Advanced' },
{ label: 'Developer Options' },
{ label: 'Experimental Features' },
],
},
};
// Dropdown with click handlers
export const WithClickHandlers: Story = {
args: {
trigger: 'Actions',
items: [
{
label: 'Alert',
onClick: () => alert('Alert clicked!'),
},
{
label: 'Console Log',
onClick: () => console.log('Console log clicked!'),
},
{ type: 'separator' },
{
label: 'Disabled Action',
disabled: true,
onClick: () => console.log('This should not fire'),
},
],
},
};
// Different alignments
export const AlignmentVariations: Story = {
render: () => ({
components: { DropdownMenu },
template: `
<div style="display: flex; justify-content: space-around; padding: 100px 20px;">
<DropdownMenu
trigger="Align Start"
:items="[{ label: 'Item 1' }, { label: 'Item 2' }, { label: 'Item 3' }]"
align="start"
/>
<DropdownMenu
trigger="Align Center"
:items="[{ label: 'Item 1' }, { label: 'Item 2' }, { label: 'Item 3' }]"
align="center"
/>
<DropdownMenu
trigger="Align End"
:items="[{ label: 'Item 1' }, { label: 'Item 2' }, { label: 'Item 3' }]"
align="end"
/>
</div>
`,
}),
};
export const IconDropdown: Story = {
// Different side positions
export const SidePositions: Story = {
render: () => ({
components: {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
Button,
MoreVertical,
},
template: `
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="ghost" size="icon">
<MoreVertical class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Duplicate</DropdownMenuItem>
<DropdownMenuItem>Delete</DropdownMenuItem>
<DropdownMenuArrow />
</DropdownMenuContent>
components: { DropdownMenu },
template: `
<div style="display: flex; justify-content: center; align-items: center; height: 400px; gap: 40px;">
<DropdownMenu
trigger="Top"
:items="[{ label: 'Item 1' }, { label: 'Item 2' }]"
side="top"
/>
<DropdownMenu
trigger="Right"
:items="[{ label: 'Item 1' }, { label: 'Item 2' }]"
side="right"
/>
<DropdownMenu
trigger="Bottom"
:items="[{ label: 'Item 1' }, { label: 'Item 2' }]"
side="bottom"
/>
<DropdownMenu
trigger="Left"
:items="[{ label: 'Item 1' }, { label: 'Item 2' }]"
side="left"
/>
</div>
`,
}),
};
// Custom trigger with slot
export const CustomTrigger: Story = {
render: () => ({
components: { DropdownMenu },
template: `
<DropdownMenu
:items="[
{ label: 'Edit' },
{ label: 'Duplicate' },
{ type: 'separator' },
{ label: 'Archive' },
{ label: 'Delete' }
]"
>
<template #trigger>
<button style="padding: 8px 16px; background: #3b82f6; color: white; border: none; border-radius: 4px; cursor: pointer;">
Custom Button Trigger
</button>
</template>
</DropdownMenu>
`,
}),
};
// Long menu with scroll
export const LongMenuWithScroll: Story = {
args: {
trigger: 'Many Options',
items: Array.from({ length: 20 }, (_, i) => ({
label: `Option ${i + 1}`,
})),
},
};
// Empty state
export const EmptyState: Story = {
args: {
trigger: 'Empty Menu',
items: [],
},
};

View File

@@ -1,10 +1,10 @@
import type { Meta, StoryObj } from "@storybook/vue3";
import Bar from "../../../src/components/common/loading/Bar.vue";
import Error from "../../../src/components/common/loading/Error.vue";
import Spinner from "../../../src/components/common/loading/Spinner.vue";
import type { Meta, StoryObj } from '@storybook/vue3';
import Bar from '../../../src/components/common/loading/Bar.vue';
import Error from '../../../src/components/common/loading/Error.vue';
import Spinner from '../../../src/components/common/loading/Spinner.vue';
const meta = {
title: "Components/Common/Loading",
title: 'Components/Common/Loading',
component: Bar,
subcomponents: { Bar, Spinner, Error },
} satisfies Meta<typeof Bar>;
@@ -17,17 +17,17 @@ type ErrorStory = StoryObj<typeof Error>;
export const LoadingBar: BarStory = {
args: {},
render: (args) => ({
render: () => ({
components: { Bar },
template: `<div class="w-full max-w-md"><Bar v-bind="args" /></div>`,
template: `<div class="w-full max-w-md"><Bar /></div>`,
}),
};
export const LoadingSpinner: SpinnerStory = {
args: {},
render: (args) => ({
render: () => ({
components: { Spinner },
template: `<div class="p-4"><Spinner v-bind="args" /></div>`,
template: `<div class="p-4"><Spinner /></div>`,
}),
};
@@ -35,7 +35,7 @@ export const LoadingError: ErrorStory = {
args: {
loading: false,
error: null,
class: "",
class: '',
},
render: (args) => ({
components: { Error },
@@ -50,4 +50,4 @@ export const LoadingError: ErrorStory = {
</div>
`,
}),
};
};

View File

@@ -7,11 +7,7 @@ import SheetFooter from '../../../src/components/common/sheet/SheetFooter.vue';
import SheetHeader from '../../../src/components/common/sheet/SheetHeader.vue';
import SheetTitle from '../../../src/components/common/sheet/SheetTitle.vue';
import SheetTrigger from '../../../src/components/common/sheet/SheetTrigger.vue';
import Select from '../../../src/components/form/select/Select.vue';
import SelectContent from '../../../src/components/form/select/SelectContent.vue';
import SelectItem from '../../../src/components/form/select/SelectItem.vue';
import SelectTrigger from '../../../src/components/form/select/SelectTrigger.vue';
import SelectValue from '../../../src/components/form/select/SelectValue.vue';
import { Select } from '../../../src/components/form/select';
const meta = {
title: 'Components/Common',
@@ -38,7 +34,7 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const Sheet: Story = {
render: (args) => ({
render: () => ({
components: {
SheetComponent,
SheetTrigger,
@@ -107,10 +103,6 @@ export const SheetWithSelect: Story = {
SheetFooter,
Button,
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
},
data() {
return {
@@ -134,16 +126,15 @@ export const SheetWithSelect: Story = {
<div class="space-y-4">
<div class="space-y-2">
<label class="text-sm font-medium">Theme</label>
<Select v-model="theme">
<SelectTrigger>
<SelectValue placeholder="Select a theme" />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem>
</SelectContent>
</Select>
<Select
v-model="theme"
placeholder="Select a theme"
:items="[
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'system', label: 'System' }
]"
/>
</div>
</div>
</div>

View File

@@ -1,92 +1,349 @@
import { Select } from '@/components/form/select';
import type { Meta, StoryObj } from '@storybook/vue3';
import {
Select as SelectComponent,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/form/select';
import { ref } from 'vue';
const meta = {
title: 'Components/Form/Select',
component: SelectComponent,
} satisfies Meta<typeof SelectComponent>;
component: Select,
parameters: {
docs: {
description: {
component:
'A custom Select component that accepts an items prop for easy rendering of options. Supports simple arrays, object arrays, and grouped items with labels and separators.',
},
},
},
argTypes: {
placeholder: {
control: 'text',
description: 'Placeholder text when no value is selected',
},
disabled: {
control: 'boolean',
description: 'Whether the select is disabled',
},
multiple: {
control: 'boolean',
description: 'Whether multiple items can be selected',
},
valueKey: {
control: 'text',
description: 'Key to use for item values when using object arrays',
},
labelKey: {
control: 'text',
description: 'Key to use for item labels when using object arrays',
},
},
} satisfies Meta<typeof Select>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Select: Story = {
export const SimpleArray: Story = {
render: (args) => ({
components: {
SelectComponent,
SelectTrigger,
SelectValue,
SelectContent,
SelectGroup,
SelectLabel,
SelectItem,
},
components: { Select },
setup() {
return { args };
},
template: `
<SelectComponent>
<SelectTrigger class="w-[180px]">
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Fruits</SelectLabel>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
<SelectItem value="orange">Orange</SelectItem>
<SelectItem value="grape">Grape</SelectItem>
</SelectGroup>
</SelectContent>
</SelectComponent>
`,
}),
};
const value = ref(null);
export const Grouped: Story = {
render: (args) => ({
components: {
SelectComponent,
SelectTrigger,
SelectValue,
SelectContent,
SelectGroup,
SelectLabel,
SelectItem,
},
setup() {
return { args };
return { args, value };
},
template: `
<div>
<SelectComponent>
<SelectTrigger class="w-[180px]">
<SelectValue placeholder="Select a food" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Fruits</SelectLabel>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
<SelectItem value="grape">Grape</SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel>Vegetables</SelectLabel>
<SelectItem value="carrot">Carrot</SelectItem>
<SelectItem value="potato">Potato</SelectItem>
<SelectItem value="celery">Celery</SelectItem>
</SelectGroup>
</SelectContent>
</SelectComponent>
<div class="space-y-4">
<Select
v-model="value"
:items="args.items"
:placeholder="args.placeholder"
:valueKey="args.valueKey"
:labelKey="args.labelKey"
/>
<p class="text-sm text-muted-foreground">Selected: {{ value }}</p>
<p class="text-sm">Items: {{ args.items }}</p>
</div>
`,
}),
args: {
placeholder: 'Select a fruit',
items: ['Apple', 'Banana', 'Orange', 'Grape', 'Mango'],
},
};
export const ObjectArray: Story = {
render: (args) => ({
components: { Select },
setup() {
const value = ref(null);
return { args, value };
},
template: `
<div class="space-y-4">
<Select
v-model="value"
:items="args.items"
:placeholder="args.placeholder"
:valueKey="args.valueKey"
:labelKey="args.labelKey"
/>
<p class="text-sm text-muted-foreground">Selected: {{ value }}</p>
</div>
`,
}),
args: {
placeholder: 'Select a color',
items: [
{ value: 'red', label: 'Red' },
{ value: 'green', label: 'Green' },
{ value: 'blue', label: 'Blue' },
{ value: 'yellow', label: 'Yellow' },
{ value: 'purple', label: 'Purple' },
],
},
};
export const GroupedItems: Story = {
render: (args) => ({
components: { Select },
setup() {
const value = ref(null);
return { args, value };
},
template: `
<div class="space-y-4">
<Select
v-model="value"
:items="args.items"
:placeholder="args.placeholder"
:valueKey="args.valueKey"
:labelKey="args.labelKey"
/>
<p class="text-sm text-muted-foreground">Selected: {{ value }}</p>
</div>
`,
}),
args: {
placeholder: 'Select food',
items: [
[
{ type: 'label', label: 'Fruits' },
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
{ value: 'orange', label: 'Orange' },
],
[
{ type: 'label', label: 'Vegetables' },
{ value: 'carrot', label: 'Carrot' },
{ value: 'lettuce', label: 'Lettuce' },
{ value: 'tomato', label: 'Tomato' },
],
],
},
};
export const WithDisabledItems: Story = {
render: (args) => ({
components: { Select },
setup() {
const value = ref(null);
return { args, value };
},
template: `
<div class="space-y-4">
<Select
v-model="value"
:items="args.items"
:placeholder="args.placeholder"
:valueKey="args.valueKey"
:labelKey="args.labelKey"
/>
<p class="text-sm text-muted-foreground">Selected: {{ value }}</p>
</div>
`,
}),
args: {
placeholder: 'Select an option',
items: [
{ value: 'active', label: 'Active' },
{ value: 'disabled1', label: 'Disabled Option 1', disabled: true },
{ value: 'enabled', label: 'Enabled' },
{ value: 'disabled2', label: 'Disabled Option 2', disabled: true },
],
},
};
export const WithSeparators: Story = {
render: (args) => ({
components: { Select },
setup() {
const value = ref(null);
return { args, value };
},
template: `
<div class="space-y-4">
<Select
v-model="value"
:items="args.items"
:placeholder="args.placeholder"
:valueKey="args.valueKey"
:labelKey="args.labelKey"
/>
<p class="text-sm text-muted-foreground">Selected: {{ value }}</p>
</div>
`,
}),
args: {
placeholder: 'Select action',
items: [
{ value: 'new', label: 'New File' },
{ value: 'open', label: 'Open File' },
{ type: 'separator' },
{ value: 'save', label: 'Save' },
{ value: 'saveas', label: 'Save As...' },
{ type: 'separator' },
{ value: 'exit', label: 'Exit' },
],
},
};
export const ControlledValue: Story = {
render: (args) => ({
components: { Select },
setup() {
const value = ref('banana');
return { args, value };
},
template: `
<div class="space-y-4">
<Select
v-model="value"
:items="args.items"
:placeholder="args.placeholder"
:valueKey="args.valueKey"
:labelKey="args.labelKey"
/>
<p class="text-sm text-muted-foreground">Selected value: {{ value }}</p>
</div>
`,
}),
args: {
placeholder: 'Select a fruit',
items: [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
{ value: 'orange', label: 'Orange' },
],
},
};
export const MultipleSelection: Story = {
render: (args) => ({
components: { Select },
setup() {
const value = ref(['apple', 'orange']);
return { args, value };
},
template: `
<div class="space-y-4">
<Select
v-model="value"
:items="args.items"
:placeholder="args.placeholder"
:multiple="args.multiple"
:valueKey="args.valueKey"
:labelKey="args.labelKey"
/>
<p class="text-sm text-muted-foreground">Selected values: {{ Array.isArray(value) ? value.join(', ') : value }}</p>
</div>
`,
}),
args: {
placeholder: 'Select fruits',
multiple: true,
items: [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
{ value: 'orange', label: 'Orange' },
{ value: 'grape', label: 'Grape' },
{ value: 'mango', label: 'Mango' },
],
},
};
export const CustomSlots: Story = {
render: (args) => ({
components: { Select },
setup() {
const value = ref(null);
return { args, value };
},
template: `
<div class="space-y-4">
<Select
v-model="value"
:items="args.items"
:placeholder="args.placeholder"
:valueKey="args.valueKey"
:labelKey="args.labelKey"
>
<template #item="{ item }">
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full" :style="{ backgroundColor: item.color }"></span>
{{ item.label }}
</div>
</template>
</Select>
<p class="text-sm text-muted-foreground">Selected: {{ value }}</p>
</div>
`,
}),
args: {
placeholder: 'Select a color',
items: [
{ value: 'red', label: 'Red', color: '#ef4444' },
{ value: 'green', label: 'Green', color: '#22c55e' },
{ value: 'blue', label: 'Blue', color: '#3b82f6' },
{ value: 'yellow', label: 'Yellow', color: '#eab308' },
{ value: 'purple', label: 'Purple', color: '#a855f7' },
],
},
};
export const DisabledSelect: Story = {
render: (args) => ({
components: { Select },
setup() {
const value = ref(null);
return { args, value };
},
template: `
<div class="space-y-4">
<Select
v-model="value"
:items="args.items"
:placeholder="args.placeholder"
:disabled="args.disabled"
:valueKey="args.valueKey"
:labelKey="args.labelKey"
/>
<p class="text-sm text-muted-foreground">Selected: {{ value }}</p>
<p class="text-xs text-muted-foreground">The entire select is disabled</p>
</div>
`,
}),
args: {
placeholder: 'This select is disabled',
disabled: true,
items: [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
{ value: 'option3', label: 'Option 3' },
],
},
};

View File

@@ -77,10 +77,10 @@ const stubs = {
UpcUptimeExpire: { template: '<div data-testid="uptime-expire"></div>', props: ['t'] },
UpcServerState: { template: '<div data-testid="server-state"></div>', props: ['t'] },
NotificationsSidebar: { template: '<div data-testid="notifications-sidebar"></div>' },
UpcDropdownMenu: {
DropdownMenu: {
template: '<div data-testid="dropdown-menu"><slot name="trigger" /><slot /></div>',
props: ['t'],
},
UpcDropdownContent: { template: '<div data-testid="dropdown-content"></div>', props: ['t'] },
UpcDropdownTrigger: { template: '<button data-testid="dropdown-trigger"></button>', props: ['t'] },
};

View File

@@ -40,5 +40,9 @@ const MockBrandButton = {
vi.mock('@unraid/ui', () => ({
cn: mockCn,
BrandButton: MockBrandButton,
DropdownMenu: {
name: 'DropdownMenu',
template: '<div><slot name="trigger" /><slot /></div>',
},
// Add other UI components as needed
}));

View File

@@ -10,18 +10,9 @@ import {
AccordionTrigger,
Button,
Dialog,
DialogDescription,
DialogFooter,
DialogHeader,
DialogScrollContent,
DialogTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@unraid/ui';
import { extractGraphQLErrorMessage } from '~/helpers/functions';
@@ -185,7 +176,7 @@ async function upsertKey() {
const fragmentData = useFragment(API_KEY_FRAGMENT_WITH_KEY, apiKeyResult.create);
apiKeyStore.setCreatedKey(fragmentData);
}
modalVisible.value = false;
editingKey.value = null;
newKeyName.value = '';
@@ -200,135 +191,119 @@ async function upsertKey() {
<template>
<Dialog
:open="modalVisible"
@close="close"
@update:open="
v-model="modalVisible"
size="lg"
:title="editingKey ? t('Edit API Key') : t('Create API Key')"
:scrollable="true"
close-button-text="Cancel"
:primary-button-text="editingKey ? 'Save' : 'Create'"
:primary-button-loading="loading || postCreateLoading"
:primary-button-loading-text="editingKey ? 'Saving...' : 'Creating...'"
:primary-button-disabled="loading || postCreateLoading"
@update:model-value="
(v) => {
if (!v) close();
}
"
@primary-click="upsertKey"
>
<DialogScrollContent class="max-w-800px">
<DialogHeader>
<DialogTitle>{{ editingKey ? t('Edit API Key') : t('Create API Key') }}</DialogTitle>
</DialogHeader>
<DialogDescription>
<form @submit.prevent="upsertKey">
<div class="mb-2">
<Label for="api-key-name">Name</Label>
<Input id="api-key-name" v-model="newKeyName" placeholder="Name" class="mt-1" />
</div>
<div class="mb-2">
<Label for="api-key-desc">Description</Label>
<Input
id="api-key-desc"
v-model="newKeyDescription"
placeholder="Description"
class="mt-1"
/>
</div>
<div class="mb-2">
<Label for="api-key-roles">Roles</Label>
<Select v-model="newKeyRoles" multiple class="mt-1 w-full">
<SelectTrigger>
<SelectValue placeholder="Select Roles" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="role in possibleRoles" :key="role" :value="role">{{
role
}}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="mb-2">
<Label for="api-key-permissions">Permissions</Label>
<Accordion id="api-key-permissions" type="single" collapsible class="w-full mt-2">
<AccordionItem value="permissions">
<AccordionTrigger>
<PermissionCounter
:permissions="newKeyPermissions"
:possible-permissions="possiblePermissions"
/>
</AccordionTrigger>
<AccordionContent>
<div class="flex flex-row justify-end my-2">
<Button
size="sm"
variant="outline"
type="button"
@click="
areAllPermissionsSelected() ? clearAllPermissions() : selectAllPermissions()
"
>
{{ areAllPermissionsSelected() ? 'Clear All' : 'Select All' }}
</Button>
</div>
<div class="flex flex-col gap-2 mt-1">
<div
v-for="perm in possiblePermissions"
:key="perm.resource"
class="rounded-sm p-2 border"
>
<div class="flex items-center justify-between mb-1">
<span class="font-semibold">{{ perm.resource }}</span>
<Button
size="sm"
variant="link"
type="button"
@click="
areAllActionsSelected(perm.resource)
? clearAllActions(perm.resource)
: selectAllActions(perm.resource)
<div class="max-w-800px">
<form @submit.prevent="upsertKey">
<div class="mb-2">
<Label for="api-key-name">Name</Label>
<Input id="api-key-name" v-model="newKeyName" placeholder="Name" class="mt-1" />
</div>
<div class="mb-2">
<Label for="api-key-desc">Description</Label>
<Input id="api-key-desc" v-model="newKeyDescription" placeholder="Description" class="mt-1" />
</div>
<div class="mb-2">
<Label for="api-key-roles">Roles</Label>
<Select
v-model="newKeyRoles"
:items="possibleRoles"
:multiple="true"
:placeholder="'Select Roles'"
class="mt-1 w-full"
/>
</div>
<div class="mb-2">
<Label for="api-key-permissions">Permissions</Label>
<Accordion id="api-key-permissions" type="single" collapsible class="w-full mt-2">
<AccordionItem value="permissions">
<AccordionTrigger>
<PermissionCounter
:permissions="newKeyPermissions"
:possible-permissions="possiblePermissions"
/>
</AccordionTrigger>
<AccordionContent>
<div class="flex flex-row justify-end my-2">
<Button
size="sm"
variant="outline"
type="button"
@click="areAllPermissionsSelected() ? clearAllPermissions() : selectAllPermissions()"
>
{{ areAllPermissionsSelected() ? 'Select None' : 'Select All' }}
</Button>
</div>
<div class="flex flex-col gap-2 mt-1">
<div
v-for="perm in possiblePermissions"
:key="perm.resource"
class="rounded-sm p-2 border"
>
<div class="flex items-center justify-between mb-1">
<span class="font-semibold">{{ perm.resource }}</span>
<Button
size="sm"
variant="link"
type="button"
@click="
areAllActionsSelected(perm.resource)
? clearAllActions(perm.resource)
: selectAllActions(perm.resource)
"
>
{{ areAllActionsSelected(perm.resource) ? 'Select None' : 'Select All' }}
</Button>
</div>
<div class="flex gap-4 flex-wrap">
<label
v-for="action in perm.actions"
:key="action"
class="flex items-center gap-1"
>
<input
type="checkbox"
:checked="
!!newKeyPermissions.find(
(p) => p.resource === perm.resource && p.actions.includes(action)
)
"
>
{{ areAllActionsSelected(perm.resource) ? 'Clear All' : 'Select All' }}
</Button>
</div>
<div class="flex gap-4 flex-wrap">
<label
v-for="action in perm.actions"
:key="action"
class="flex items-center gap-1"
>
<input
type="checkbox"
:checked="
!!newKeyPermissions.find(
(p) => p.resource === perm.resource && p.actions.includes(action)
@change="
(e: Event) =>
togglePermission(
perm.resource,
action,
(e.target as HTMLInputElement)?.checked
)
"
@change="
(e: Event) =>
togglePermission(
perm.resource,
action,
(e.target as HTMLInputElement)?.checked
)
"
/>
<span class="text-sm">{{ action }}</span>
</label>
</div>
"
/>
<span class="text-sm">{{ action }}</span>
</label>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
<div v-if="error" class="text-red-500 mt-2 text-sm">
{{ extractGraphQLErrorMessage(error) }}
</div>
</form>
</DialogDescription>
<DialogFooter>
<Button variant="secondary" @click="close">Cancel</Button>
<Button variant="primary" :disabled="loading || postCreateLoading" @click="upsertKey()">
<span v-if="loading || postCreateLoading">
{{ editingKey ? 'Saving...' : 'Creating...' }}
</span>
<span v-else>{{ editingKey ? 'Save' : 'Create' }}</span>
</Button>
</DialogFooter>
</DialogScrollContent>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
<div v-if="error" class="text-red-500 mt-2 text-sm">
{{ extractGraphQLErrorMessage(error) }}
</div>
</form>
</div>
</Dialog>
</template>

View File

@@ -4,12 +4,6 @@ import { useMutation, useQuery, useSubscription } from '@vue/apollo-composable';
import {
Button,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
Sheet,
SheetContent,
SheetHeader,
@@ -42,6 +36,14 @@ const { mutate: recalculateOverview } = useMutation(resetOverview);
const { determineTeleportTarget } = useTeleport();
const importance = ref<Importance | undefined>(undefined);
const filterItems = [
{ type: 'label' as const, label: 'Notification Types' },
{ label: 'All Types', value: 'all' },
{ label: 'Alert', value: Importance.ALERT },
{ label: 'Info', value: Importance.INFO },
{ label: 'Warning', value: Importance.WARNING },
];
const confirmAndArchiveAll = async () => {
if (confirm('This will archive all notifications on your Unraid server. Continue?')) {
await archiveAll();
@@ -176,26 +178,16 @@ const prepareToViewNotifications = () => {
</TabsContent>
<Select
:items="filterItems"
placeholder="Filter By"
class="h-auto"
@update:model-value="
(val: unknown) => {
const strVal = String(val);
importance = strVal === 'all' || !strVal ? undefined : (strVal as Importance);
}
"
>
<SelectTrigger class="h-auto">
<SelectValue class="text-gray-400 leading-6" placeholder="Filter By" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Notification Types</SelectLabel>
<SelectItem value="all">All Types</SelectItem>
<SelectItem :value="Importance.ALERT"> Alert </SelectItem>
<SelectItem :value="Importance.INFO">Info</SelectItem>
<SelectItem :value="Importance.WARNING">Warning</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
/>
</div>
<TabsContent value="unread" class="flex-col flex-1 min-h-0">

View File

@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';
import { useClipboard } from '@vueuse/core';
import { DropdownMenu } from '@unraid/ui';
import { devConfig } from '~/helpers/env';
import type { Server } from '~/types/server';
@@ -143,11 +144,16 @@ onMounted(() => {
<NotificationsSidebar />
<UpcDropdownMenu :t="t">
<DropdownMenu align="end" side="bottom" :side-offset="4">
<template #trigger>
<UpcDropdownTrigger :t="t" />
</template>
</UpcDropdownMenu>
<template #content>
<div class="max-w-[350px] sm:min-w-[350px]">
<UpcDropdownContent :t="t" />
</div>
</template>
</DropdownMenu>
</div>
</div>
</template>

View File

@@ -1,23 +0,0 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { DropdownMenu, DropdownMenuArrow, DropdownMenuContent, DropdownMenuTrigger } from '@unraid/ui';
import type { ComposerTranslation } from 'vue-i18n';
defineProps<{ t: ComposerTranslation }>();
const open = ref(false);
</script>
<template>
<DropdownMenu v-model:open="open">
<DropdownMenuTrigger>
<slot name="trigger" />
</DropdownMenuTrigger>
<DropdownMenuContent :side-offset="4" :align="'end'" :side="'bottom'" class="w-[350px]">
<UpcDropdownContent :t="t" />
<DropdownMenuArrow :rounded="true" class="fill-popover" :height="10" :width="16" />
</DropdownMenuContent>
</DropdownMenu>
</template>

View File

@@ -11,15 +11,12 @@ import {
import type { ComposerTranslation } from 'vue-i18n';
import { useDropdownStore } from '~/store/dropdown';
import { useErrorsStore } from '~/store/errors';
import { useServerStore } from '~/store/server';
import { useUpdateOsStore } from '~/store/updateOs';
const props = defineProps<{ t: ComposerTranslation }>();
const dropdownStore = useDropdownStore();
const { dropdownVisible } = storeToRefs(dropdownStore);
const { errors } = storeToRefs(useErrorsStore());
const { rebootType, state, stateData } = storeToRefs(useServerStore());
const { available: osUpdateAvailable } = storeToRefs(useUpdateOsStore());
@@ -43,15 +40,14 @@ const title = computed((): string => {
if (showErrorIcon.value) {
return props.t('Learn more about the error');
}
return dropdownVisible.value ? props.t('Close Dropdown') : props.t('Open Dropdown');
return props.t('Open Dropdown');
});
</script>
<template>
<button
class="group text-18px border-0 relative flex flex-row justify-end items-center h-full gap-x-8px opacity-100 hover:opacity-75 focus:opacity-75 transition-opacity text-header-text-primary"
class="group text-18px border-0 relative flex flex-row justify-end items-center h-full gap-x-8px opacity-100 hover:opacity-75 transition-opacity text-header-text-primary"
:title="title"
@click="dropdownStore.dropdownToggle()"
>
<template v-if="errors.length && errors[0].level">
<InformationCircleIcon

View File

@@ -1,7 +1,7 @@
declare global {
// eslint-disable-next-line no-var
var csrf_token: string;
}
// an export or import statement is required to make this file a module
export {};
export {};

View File

@@ -89,10 +89,10 @@
"@floating-ui/vue": "^1.1.5",
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.2.0",
"@jsonforms/core": "^3.5.1",
"@jsonforms/vue": "^3.5.1",
"@jsonforms/vue-vanilla": "^3.5.1",
"@jsonforms/vue-vuetify": "^3.5.1",
"@jsonforms/core": "^3.6.0",
"@jsonforms/vue": "^3.6.0",
"@jsonforms/vue-vanilla": "^3.6.0",
"@jsonforms/vue-vuetify": "^3.6.0",
"@nuxtjs/color-mode": "^3.5.2",
"@pinia/nuxt": "^0.11.0",
"@unraid/shared-callbacks": "^1.1.1",
@@ -105,7 +105,7 @@
"crypto-js": "^4.2.0",
"dayjs": "^1.11.13",
"focus-trap": "^7.6.2",
"graphql": "^16.9.0",
"graphql": "^16.11.0",
"graphql-tag": "^2.12.6",
"graphql-ws": "^6.0.0",
"hex-to-rgba": "^2.0.1",