feat: complete Epic 1 — team management & permission system
- Story 1.1: Permission enum, config, AuthorizesPermissions & HasWorkspaceScope traits, member→worker migration - Story 1.2: Team page with member list, invitation system with queued email - Story 1.3: Role assignment (Manager/Worker) and member removal with activity logging - Story 1.4: Owner-only permission toggle matrix for Managers (manage team, view logs, configure portal) - Story 1.5: Role-based access enforcement — Workers see only assigned declarations/clients, sidebar scoping - Story 1.6: Workspace switcher dropdown for multi-workspace users with session-based switching - 83 new/modified files, 182 tests passing with zero regressions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -35,8 +35,8 @@ import UserMenuContent from '@/components/UserMenuContent.vue';
|
||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||
import { getInitials } from '@/composables/useInitials';
|
||||
import { toUrl } from '@/lib/utils';
|
||||
import type { BreadcrumbItem, NavItem } from '@/types';
|
||||
import { dashboard } from '@/routes';
|
||||
import type { BreadcrumbItem, NavItem } from '@/types';
|
||||
|
||||
type Props = {
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
|
||||
@@ -9,8 +9,8 @@ import AppLogoIcon from '@/components/AppLogoIcon.vue';
|
||||
<AppLogoIcon class="size-5 fill-current text-white dark:text-black" />
|
||||
</div>
|
||||
<div class="ml-1 grid flex-1 text-left text-sm">
|
||||
<span class="mb-0.5 truncate leading-tight font-semibold"
|
||||
>{{ $page.props.name }}</span
|
||||
>
|
||||
<span class="mb-0.5 truncate leading-tight font-semibold">{{
|
||||
$page.props.name
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
HelpCircle,
|
||||
LayoutGrid,
|
||||
Users,
|
||||
UsersRound,
|
||||
} from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import NavFooter from '@/components/NavFooter.vue';
|
||||
@@ -23,11 +24,17 @@ import {
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { dashboard } from '@/routes';
|
||||
import { index as clientsIndex } from '@/routes/clients';
|
||||
import { index as declarationsIndex } from '@/routes/declarations';
|
||||
import { index as teamIndex } from '@/routes/team';
|
||||
import type { NavItem } from '@/types';
|
||||
import AppLogo from './AppLogo.vue';
|
||||
import WorkspaceSwitcher from './WorkspaceSwitcher.vue';
|
||||
|
||||
const page = usePage();
|
||||
const workspaceRole = computed(() => page.props.auth?.workspaceRole);
|
||||
const isWorker = computed(() => workspaceRole.value === 'worker');
|
||||
|
||||
const mainNavItems = computed<NavItem[]>(() => {
|
||||
const items: NavItem[] = [
|
||||
{
|
||||
@@ -37,18 +44,31 @@ const mainNavItems = computed<NavItem[]>(() => {
|
||||
},
|
||||
];
|
||||
if (page.props.auth?.currentWorkspace) {
|
||||
items.push(
|
||||
{
|
||||
title: 'Clients',
|
||||
href: '/clients',
|
||||
icon: Briefcase,
|
||||
},
|
||||
{
|
||||
title: 'Déclarations',
|
||||
href: '/declarations',
|
||||
if (isWorker.value) {
|
||||
items.push({
|
||||
title: 'Mes déclarations',
|
||||
href: declarationsIndex.url(),
|
||||
icon: FileStack,
|
||||
},
|
||||
);
|
||||
});
|
||||
} else {
|
||||
items.push(
|
||||
{
|
||||
title: 'Clients',
|
||||
href: clientsIndex.url(),
|
||||
icon: Briefcase,
|
||||
},
|
||||
{
|
||||
title: 'Déclarations',
|
||||
href: declarationsIndex.url(),
|
||||
icon: FileStack,
|
||||
},
|
||||
{
|
||||
title: 'Équipe',
|
||||
href: teamIndex.url(),
|
||||
icon: UsersRound,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Form } from '@inertiajs/vue3';
|
||||
import { useTemplateRef } from 'vue';
|
||||
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
||||
|
||||
const passwordInput = useTemplateRef('passwordInput');
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import {
|
||||
ChevronFirst,
|
||||
ChevronLast,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -10,7 +16,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { ChevronFirst, ChevronLast, ChevronLeft, ChevronRight } from 'lucide-vue-next';
|
||||
|
||||
interface PaginationData {
|
||||
current_page: number;
|
||||
@@ -33,7 +38,9 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
});
|
||||
|
||||
const canGoPrevious = computed(() => props.pagination.current_page > 1);
|
||||
const canGoNext = computed(() => props.pagination.current_page < props.pagination.last_page);
|
||||
const canGoNext = computed(
|
||||
() => props.pagination.current_page < props.pagination.last_page,
|
||||
);
|
||||
|
||||
const handlePerPageChange = (value: unknown): void => {
|
||||
const str = value != null ? String(value) : null;
|
||||
@@ -43,10 +50,14 @@ const handlePerPageChange = (value: unknown): void => {
|
||||
url.searchParams.set('per_page', perPage.toString());
|
||||
url.searchParams.set('page', '1');
|
||||
|
||||
router.get(url.pathname + url.search, {}, {
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
});
|
||||
router.get(
|
||||
url.pathname + url.search,
|
||||
{},
|
||||
{
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const goToPage = (page: number): void => {
|
||||
@@ -57,27 +68,34 @@ const goToPage = (page: number): void => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('page', page.toString());
|
||||
|
||||
router.get(url.pathname + url.search, {}, {
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
});
|
||||
router.get(
|
||||
url.pathname + url.search,
|
||||
{},
|
||||
{
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
},
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 px-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="text-muted-foreground text-sm">
|
||||
<div
|
||||
class="flex flex-col gap-4 px-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<span v-if="selectedCount > 0">
|
||||
{{ selectedCount }} sur {{ pagination.total }} ligne(s) sélectionnée(s).
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ pagination.total }} ligne(s) au total
|
||||
{{ selectedCount }} sur {{ pagination.total }} ligne(s)
|
||||
sélectionnée(s).
|
||||
</span>
|
||||
<span v-else> {{ pagination.total }} ligne(s) au total </span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:gap-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground hidden text-sm sm:inline">Lignes par page</span>
|
||||
<span class="hidden text-sm text-muted-foreground sm:inline"
|
||||
>Lignes par page</span
|
||||
>
|
||||
<Select
|
||||
:model-value="pagination.per_page.toString()"
|
||||
@update:model-value="handlePerPageChange"
|
||||
@@ -97,8 +115,9 @@ const goToPage = (page: number): void => {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="text-muted-foreground hidden text-sm md:inline">
|
||||
Page {{ pagination.current_page }} sur {{ pagination.last_page }}
|
||||
<div class="hidden text-sm text-muted-foreground md:inline">
|
||||
Page {{ pagination.current_page }} sur
|
||||
{{ pagination.last_page }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
@@ -118,7 +137,7 @@ const goToPage = (page: number): void => {
|
||||
>
|
||||
<ChevronLeft class="size-4" />
|
||||
</Button>
|
||||
<div class="text-muted-foreground mx-2 text-sm md:hidden">
|
||||
<div class="mx-2 text-sm text-muted-foreground md:hidden">
|
||||
{{ pagination.current_page }}/{{ pagination.last_page }}
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -21,8 +21,8 @@ import {
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useAppearance } from '@/composables/useAppearance';
|
||||
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
|
||||
import type { TwoFactorConfigContent } from '@/types';
|
||||
import { confirm } from '@/routes/two-factor';
|
||||
import type { TwoFactorConfigContent } from '@/types';
|
||||
|
||||
type Props = {
|
||||
requiresConfirmation: boolean;
|
||||
|
||||
@@ -71,7 +71,11 @@ const emit = defineEmits<{
|
||||
type="password"
|
||||
:required="passwordRequired"
|
||||
autocomplete="new-password"
|
||||
:placeholder="passwordRequired ? 'Password' : 'Leave blank to keep current'"
|
||||
:placeholder="
|
||||
passwordRequired
|
||||
? 'Password'
|
||||
: 'Leave blank to keep current'
|
||||
"
|
||||
aria-invalid="!!form.errors.password"
|
||||
/>
|
||||
<InputError :message="form.errors.password" />
|
||||
@@ -97,7 +101,7 @@ const emit = defineEmits<{
|
||||
id="group"
|
||||
v-model="form.group"
|
||||
required
|
||||
class="border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring h-9 w-full rounded-md border px-3 py-1 text-sm shadow-xs outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50"
|
||||
class="h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-xs outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
:aria-invalid="!!form.errors.group"
|
||||
>
|
||||
<option value="" disabled>Select a group</option>
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import UserInfo from '@/components/UserInfo.vue';
|
||||
import type { User } from '@/types';
|
||||
import { logout } from '@/routes';
|
||||
import { edit } from '@/routes/profile';
|
||||
import type { User } from '@/types';
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
|
||||
@@ -34,7 +34,7 @@ const emit = defineEmits<{
|
||||
submit: [];
|
||||
}>();
|
||||
|
||||
const defaultRole = 'member';
|
||||
const defaultRole = 'worker';
|
||||
|
||||
function onUserToggle(userId: number, checked: boolean) {
|
||||
if (checked) {
|
||||
@@ -43,9 +43,14 @@ function onUserToggle(userId: number, checked: boolean) {
|
||||
props.form.user_ids = [...ids, userId];
|
||||
}
|
||||
const roles = props.form.user_roles ?? {};
|
||||
props.form.user_roles = { ...roles, [userId]: roles[userId] ?? defaultRole };
|
||||
props.form.user_roles = {
|
||||
...roles,
|
||||
[userId]: roles[userId] ?? defaultRole,
|
||||
};
|
||||
} else {
|
||||
props.form.user_ids = (props.form.user_ids ?? []).filter((id) => id !== userId);
|
||||
props.form.user_ids = (props.form.user_ids ?? []).filter(
|
||||
(id) => id !== userId,
|
||||
);
|
||||
const roles = { ...(props.form.user_roles ?? {}) };
|
||||
delete roles[userId];
|
||||
props.form.user_roles = roles;
|
||||
@@ -113,10 +118,15 @@ function getUserRole(userId: number): string {
|
||||
type="checkbox"
|
||||
:value="user.id"
|
||||
:checked="isUserSelected(user.id)"
|
||||
class="border-input size-4 shrink-0 rounded-[4px] border focus-visible:ring-2 focus-visible:ring-ring"
|
||||
@change="onUserToggle(user.id, ($event.target as HTMLInputElement).checked)"
|
||||
class="size-4 shrink-0 rounded-[4px] border border-input focus-visible:ring-2 focus-visible:ring-ring"
|
||||
@change="
|
||||
onUserToggle(
|
||||
user.id,
|
||||
($event.target as HTMLInputElement).checked,
|
||||
)
|
||||
"
|
||||
/>
|
||||
<div class="min-w-0 flex-1 flex flex-col">
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<span class="text-sm font-medium">{{ user.name }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{
|
||||
user.email
|
||||
@@ -125,7 +135,7 @@ function getUserRole(userId: number): string {
|
||||
<select
|
||||
:value="getUserRole(user.id)"
|
||||
:disabled="!isUserSelected(user.id)"
|
||||
class="border-input bg-background h-8 shrink-0 rounded-md border px-2 text-sm disabled:opacity-50"
|
||||
class="h-8 shrink-0 rounded-md border border-input bg-background px-2 text-sm disabled:opacity-50"
|
||||
@change="
|
||||
onRoleChange(
|
||||
user.id,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, router, usePage } from '@inertiajs/vue3';
|
||||
import { BoxSelect, Building2, ChevronsUpDown, Plus } from 'lucide-vue-next';
|
||||
import { computed, ref } from 'vue';
|
||||
import { router, usePage } from '@inertiajs/vue3';
|
||||
import { BoxSelect, Check, ChevronsUpDown } from 'lucide-vue-next';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
@@ -19,30 +19,58 @@ import {
|
||||
const page = usePage();
|
||||
const { isMobile } = useSidebar();
|
||||
|
||||
const workspaces = page.props.auth?.workspaces ?? [];
|
||||
const currentWorkspace = page.props.auth?.currentWorkspace ?? null;
|
||||
const workspaces = computed(() => page.props.auth?.workspaces ?? []);
|
||||
const currentWorkspace = computed(
|
||||
() => page.props.auth?.currentWorkspace ?? null,
|
||||
);
|
||||
const workspaceSwitchUrl = computed(
|
||||
() => page.props.auth?.workspaceSwitchUrl ?? '',
|
||||
);
|
||||
const canSwitch = computed(() => workspaces.value.length > 1);
|
||||
|
||||
const isSwitching = ref(false);
|
||||
|
||||
function getInitial(name: string): string {
|
||||
return name.charAt(0).toUpperCase();
|
||||
}
|
||||
|
||||
function switchWorkspace(workspace: { id: number }) {
|
||||
router.post('/workspace/switch', { workspace_id: workspace.id }, {
|
||||
preserveState: false,
|
||||
onSuccess: () => router.reload(),
|
||||
});
|
||||
if (isSwitching.value || workspace.id === currentWorkspace.value?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSwitching.value = true;
|
||||
router.post(
|
||||
workspaceSwitchUrl.value,
|
||||
{ workspace_id: workspace.id },
|
||||
{
|
||||
preserveState: false,
|
||||
onFinish: () => {
|
||||
isSwitching.value = false;
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarMenu v-if="workspaces.length > 0">
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<!-- Multi-workspace: show dropdown -->
|
||||
<DropdownMenu v-if="canSwitch">
|
||||
<DropdownMenuTrigger as-child :disabled="isSwitching">
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<div
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sm font-semibold text-sidebar-primary-foreground"
|
||||
>
|
||||
<Building2 class="size-4" />
|
||||
{{
|
||||
currentWorkspace
|
||||
? getInitial(currentWorkspace.name)
|
||||
: '?'
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
class="grid flex-1 text-left text-sm leading-tight"
|
||||
@@ -75,46 +103,57 @@ function switchWorkspace(workspace: { id: number }) {
|
||||
v-for="workspace in workspaces"
|
||||
:key="workspace.id"
|
||||
class="gap-2 p-2"
|
||||
:disabled="isSwitching"
|
||||
@click="switchWorkspace(workspace)"
|
||||
>
|
||||
<div
|
||||
class="flex size-6 items-center justify-center rounded-sm border"
|
||||
class="flex size-6 items-center justify-center rounded-sm border text-xs font-semibold"
|
||||
>
|
||||
<Building2 class="size-3.5 shrink-0" />
|
||||
{{ getInitial(workspace.name) }}
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-1 flex-col">
|
||||
<span>{{ workspace.name }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{
|
||||
workspace.slug
|
||||
}}</span>
|
||||
</div>
|
||||
<Check
|
||||
v-if="currentWorkspace?.id === workspace.id"
|
||||
class="ml-auto size-4 text-primary"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<!-- <DropdownMenuSeparator />
|
||||
<DropdownMenuItem as-child>
|
||||
<Link
|
||||
href="/workspaces"
|
||||
class="flex w-full cursor-pointer items-center gap-2 p-2"
|
||||
>
|
||||
<div
|
||||
class="flex size-6 items-center justify-center rounded-md border bg-transparent"
|
||||
>
|
||||
<Plus class="size-4" />
|
||||
</div>
|
||||
<span class="font-medium text-muted-foreground">
|
||||
Manage workspaces
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem> -->
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<!-- Single workspace: static display -->
|
||||
<SidebarMenuButton v-else size="lg">
|
||||
<div
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sm font-semibold text-sidebar-primary-foreground"
|
||||
>
|
||||
{{
|
||||
currentWorkspace
|
||||
? getInitial(currentWorkspace.name)
|
||||
: '?'
|
||||
}}
|
||||
</div>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-medium">
|
||||
{{ currentWorkspace?.name ?? 'Select workspace' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="currentWorkspace"
|
||||
class="truncate text-xs text-muted-foreground"
|
||||
>
|
||||
{{ currentWorkspace.slug }}
|
||||
</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
<SidebarMenu v-else>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" as-child>
|
||||
<div
|
||||
class="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-muted-foreground">
|
||||
<div
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-lg border border-dashed"
|
||||
>
|
||||
|
||||
38
resources/js/components/ui/switch/Switch.vue
Normal file
38
resources/js/components/ui/switch/Switch.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import type { SwitchRootEmits, SwitchRootProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
SwitchRoot,
|
||||
SwitchThumb,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<SwitchRootProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const emits = defineEmits<SwitchRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SwitchRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="switch"
|
||||
v-bind="forwarded"
|
||||
:class="cn(
|
||||
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<SwitchThumb
|
||||
data-slot="switch-thumb"
|
||||
:class="cn('bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0')"
|
||||
>
|
||||
<slot name="thumb" v-bind="slotProps" />
|
||||
</SwitchThumb>
|
||||
</SwitchRoot>
|
||||
</template>
|
||||
1
resources/js/components/ui/switch/index.ts
Normal file
1
resources/js/components/ui/switch/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Switch } from "./Switch.vue"
|
||||
Reference in New Issue
Block a user