KAN-18 Basic UI components #112
@@ -1,18 +1,35 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { AvatarRootProps } from "reka-ui";
|
||||||
import { AvatarRoot } from "reka-ui"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { cn } from "@/lib/utils"
|
import type { AvatarVariants } from ".";
|
||||||
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
|
import { AvatarRoot, useForwardProps } from "reka-ui";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { avatarVariants } from ".";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
class?: HTMLAttributes["class"]
|
defineProps<
|
||||||
}>()
|
AvatarRootProps & {
|
||||||
|
class?: HTMLAttributes["class"];
|
||||||
|
size?: AvatarVariants["size"];
|
||||||
|
shape?: AvatarVariants["shape"];
|
||||||
|
}
|
||||||
|
>(),
|
||||||
|
{ size: "default", shape: "circle" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class", "size", "shape");
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AvatarRoot
|
<AvatarRoot
|
||||||
data-slot="avatar"
|
data-slot="avatar"
|
||||||
:class="cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', props.class)"
|
:data-size="size"
|
||||||
>
|
:data-shape="shape"
|
||||||
<slot />
|
v-bind="forwardedProps"
|
||||||
</AvatarRoot>
|
:class="cn(avatarVariants({ size, shape }), props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</AvatarRoot>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AvatarFallbackProps } from "reka-ui"
|
import type { AvatarFallbackProps } from "reka-ui";
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
import { AvatarFallback } from "reka-ui"
|
import { AvatarFallback } from "reka-ui";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const props = defineProps<AvatarFallbackProps & { class?: HTMLAttributes["class"] }>()
|
const props = defineProps<
|
||||||
|
AvatarFallbackProps & { class?: HTMLAttributes["class"] }
|
||||||
|
>();
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class")
|
const delegatedProps = reactiveOmit(props, "class");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AvatarFallback
|
<AvatarFallback
|
||||||
data-slot="avatar-fallback"
|
data-slot="avatar-fallback"
|
||||||
v-bind="delegatedProps"
|
v-bind="delegatedProps"
|
||||||
:class="cn('bg-muted flex size-full items-center justify-center rounded-full', props.class)"
|
:class="
|
||||||
>
|
cn(
|
||||||
<slot />
|
'bg-accent text-accent-foreground flex size-full items-center justify-center font-semibold',
|
||||||
</AvatarFallback>
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</AvatarFallback>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,41 +1,39 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { getInitials } from "@/composables/useInitials";
|
import type { AvatarVariants } from "@/components/ui/avatar";
|
||||||
import {
|
import type { UserModel } from "@/types/models/user-model";
|
||||||
Avatar,
|
|
||||||
AvatarFallback,
|
|
||||||
AvatarImage,
|
|
||||||
} from "@/components/ui/avatar/index";
|
|
||||||
import { usePage } from "@inertiajs/vue3";
|
import { usePage } from "@inertiajs/vue3";
|
||||||
import { computed, PropType } from "vue";
|
import { computed } from "vue";
|
||||||
import { UserModel } from "@/types/models/user-model";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { getInitials } from "@/composables/useInitials";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = withDefaults(
|
||||||
user: {
|
defineProps<{
|
||||||
type: Object as PropType<UserModel>,
|
user?: UserModel;
|
||||||
|
rounded?: boolean;
|
||||||
|
size?: AvatarVariants["size"];
|
||||||
|
label?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
rounded: true,
|
||||||
|
size: "default",
|
||||||
},
|
},
|
||||||
rounded: {
|
);
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const page = usePage();
|
const page = usePage();
|
||||||
const auth = computed(() => page.props.auth);
|
const user = computed(() => props.user ?? page.props.auth.user);
|
||||||
|
const accessibleLabel = computed(() => props.label ?? user.value.name);
|
||||||
const user = computed(() => props.user ?? auth.value.user)
|
const initials = computed(() => getInitials(user.value.name));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Avatar class="size-8 overflow-hidden" :class="rounded ? 'rounded-full' : 'rounded-lg'">
|
<Avatar :size="size" :shape="rounded ? 'circle' : 'rounded'">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
v-if="user.avatar"
|
v-if="user.avatar"
|
||||||
:src="user.avatar"
|
:src="user.avatar"
|
||||||
:alt="user.name"
|
:alt="accessibleLabel"
|
||||||
/>
|
/>
|
||||||
<AvatarFallback
|
<AvatarFallback :aria-label="accessibleLabel">
|
||||||
class="rounded-lg bg-neutral-200 font-semibold text-black dark:bg-neutral-700 dark:text-white"
|
{{ initials }}
|
||||||
>
|
|
||||||
{{ getInitials(auth.user?.name) }}
|
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,3 +1,31 @@
|
|||||||
export { default as Avatar } from "./Avatar.vue"
|
import type { VariantProps } from "class-variance-authority";
|
||||||
export { default as AvatarFallback } from "./AvatarFallback.vue"
|
import { cva } from "class-variance-authority";
|
||||||
export { default as AvatarImage } from "./AvatarImage.vue"
|
|
||||||
|
export { default as Avatar } from "./Avatar.vue";
|
||||||
|
export { default as AvatarFallback } from "./AvatarFallback.vue";
|
||||||
|
export { default as AvatarImage } from "./AvatarImage.vue";
|
||||||
|
export { default as UserAvatar } from "./UserAvatar.vue";
|
||||||
|
|
||||||
|
export const avatarVariants = cva(
|
||||||
|
"border-border bg-muted relative flex shrink-0 overflow-hidden border",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
sm: "size-6 text-label",
|
||||||
|
default: "size-8 text-caption",
|
||||||
|
lg: "size-10 text-body",
|
||||||
|
xl: "size-16 text-title",
|
||||||
|
},
|
||||||
|
shape: {
|
||||||
|
circle: "rounded-full",
|
||||||
|
rounded: "rounded-lg",
|
||||||
|
square: "rounded-sm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: "default",
|
||||||
|
shape: "circle",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
export type AvatarVariants = VariantProps<typeof avatarVariants>;
|
||||||
|
|||||||
@@ -1,26 +1,28 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { PrimitiveProps } from "reka-ui"
|
import type { PrimitiveProps } from "reka-ui";
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import type { BadgeVariants } from "."
|
import type { BadgeVariants } from ".";
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
import { Primitive } from "reka-ui"
|
import { Primitive } from "reka-ui";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { badgeVariants } from "."
|
import { badgeVariants } from ".";
|
||||||
|
|
||||||
const props = defineProps<PrimitiveProps & {
|
const props = defineProps<
|
||||||
variant?: BadgeVariants["variant"]
|
PrimitiveProps & {
|
||||||
class?: HTMLAttributes["class"]
|
variant?: BadgeVariants["variant"];
|
||||||
}>()
|
class?: HTMLAttributes["class"];
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class")
|
const delegatedProps = reactiveOmit(props, "class", "variant");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Primitive
|
<Primitive
|
||||||
data-slot="badge"
|
data-slot="badge"
|
||||||
:class="cn(badgeVariants({ variant }), props.class)"
|
:class="cn(badgeVariants({ variant }), props.class)"
|
||||||
v-bind="delegatedProps"
|
v-bind="delegatedProps"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</Primitive>
|
</Primitive>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,26 +1,31 @@
|
|||||||
import type { VariantProps } from "class-variance-authority"
|
import type { VariantProps } from "class-variance-authority";
|
||||||
import { cva } from "class-variance-authority"
|
import { cva } from "class-variance-authority";
|
||||||
|
|
||||||
export { default as Badge } from "./Badge.vue"
|
export { default as Badge } from "./Badge.vue";
|
||||||
|
|
||||||
export const badgeVariants = cva(
|
export const badgeVariants = cva(
|
||||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2.5 py-1 text-label whitespace-nowrap transition-[background-color,border-color,color,box-shadow] outline-none [&>svg]:pointer-events-none [&>svg]:size-3 [&>svg]:shrink-0 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
secondary:
|
secondary:
|
||||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/80",
|
||||||
destructive:
|
destructive:
|
||||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
"border-transparent bg-destructive text-destructive-foreground [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/35",
|
||||||
outline:
|
outline:
|
||||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
"border-border bg-card text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
},
|
success:
|
||||||
|
"border-transparent bg-success text-success-foreground [a&]:hover:brightness-95",
|
||||||
|
warning:
|
||||||
|
"border-transparent bg-warning text-warning-foreground [a&]:hover:brightness-95",
|
||||||
|
info: "border-transparent bg-info text-info-foreground [a&]:hover:brightness-95",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
);
|
||||||
variant: "default",
|
export type BadgeVariants = VariantProps<typeof badgeVariants>;
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
|
||||||
|
|||||||
@@ -1,29 +1,47 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { PrimitiveProps } from "reka-ui"
|
import type { PrimitiveProps } from "reka-ui";
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import type { ButtonVariants } from "."
|
import type { ButtonVariants } from ".";
|
||||||
import { Primitive } from "reka-ui"
|
import { Primitive } from "reka-ui";
|
||||||
import { cn } from "@/lib/utils"
|
import { computed } from "vue";
|
||||||
import { buttonVariants } from "."
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { buttonVariants } from ".";
|
||||||
|
|
||||||
interface Props extends PrimitiveProps {
|
interface Props extends PrimitiveProps {
|
||||||
variant?: ButtonVariants["variant"]
|
variant?: ButtonVariants["variant"];
|
||||||
size?: ButtonVariants["size"]
|
size?: ButtonVariants["size"];
|
||||||
class?: HTMLAttributes["class"]
|
class?: HTMLAttributes["class"];
|
||||||
|
disabled?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
loadingLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
as: "button",
|
as: "button",
|
||||||
})
|
disabled: false,
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isDisabled = computed(() => props.disabled || props.loading);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Primitive
|
<Primitive
|
||||||
data-slot="button"
|
data-slot="button"
|
||||||
:as="as"
|
:data-loading="loading ? '' : undefined"
|
||||||
:as-child="asChild"
|
:as="as"
|
||||||
:class="cn(buttonVariants({ variant, size }), props.class)"
|
:as-child="asChild"
|
||||||
>
|
:disabled="isDisabled"
|
||||||
<slot />
|
:aria-disabled="isDisabled ? 'true' : undefined"
|
||||||
</Primitive>
|
:aria-busy="loading ? 'true' : undefined"
|
||||||
|
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||||
|
>
|
||||||
|
<Spinner
|
||||||
|
v-if="loading"
|
||||||
|
data-slot="button-spinner"
|
||||||
|
:label="loadingLabel"
|
||||||
|
/>
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,38 +1,37 @@
|
|||||||
import type { VariantProps } from "class-variance-authority"
|
import type { VariantProps } from "class-variance-authority";
|
||||||
import { cva } from "class-variance-authority"
|
import { cva } from "class-variance-authority";
|
||||||
|
|
||||||
export { default as Button } from "./Button.vue"
|
export { default as Button } from "./Button.vue";
|
||||||
|
|
||||||
export const buttonVariants = cva(
|
export const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-body font-semibold transition-[background-color,border-color,color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[loading]:cursor-wait data-[loading]:opacity-80 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 focus-visible:ring-offset-2 focus-visible:ring-offset-background [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"bg-primary text-primary-foreground hover:bg-primary/90",
|
"border border-transparent bg-brand text-brand-foreground shadow-xs hover:brightness-95",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
|
"border border-transparent bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/35",
|
||||||
outline:
|
outline:
|
||||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
"border border-input bg-card text-foreground shadow-xs hover:border-primary/45 hover:bg-accent hover:text-accent-foreground",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
"border border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
ghost:
|
ghost: "border border-transparent hover:bg-accent hover:text-accent-foreground",
|
||||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
link: "min-h-0 border-0 px-0 text-primary underline-offset-4 shadow-none hover:underline",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
},
|
||||||
},
|
size: {
|
||||||
size: {
|
default: "min-h-control px-4 py-2 has-[>svg]:px-3",
|
||||||
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
|
sm: "min-h-9 gap-1.5 px-3 py-1.5 has-[>svg]:px-2.5",
|
||||||
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
lg: "min-h-12 px-6 py-3 has-[>svg]:px-4",
|
||||||
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
|
icon: "size-control",
|
||||||
"icon": "size-9",
|
"icon-sm": "size-9",
|
||||||
"icon-sm": "size-8",
|
"icon-lg": "size-12",
|
||||||
"icon-lg": "size-10",
|
},
|
||||||
},
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
);
|
||||||
variant: "default",
|
export type ButtonVariants = VariantProps<typeof buttonVariants>;
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
|
||||||
|
|||||||
@@ -1,35 +1,48 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui"
|
import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui";
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
import { Check } from "lucide-vue-next"
|
import { Check } from "lucide-vue-next";
|
||||||
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui"
|
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes["class"] }>()
|
const props = withDefaults(
|
||||||
const emits = defineEmits<CheckboxRootEmits>()
|
defineProps<
|
||||||
|
CheckboxRootProps & {
|
||||||
|
class?: HTMLAttributes["class"];
|
||||||
|
invalid?: boolean;
|
||||||
|
}
|
||||||
|
>(),
|
||||||
|
{ invalid: false },
|
||||||
|
);
|
||||||
|
const emits = defineEmits<CheckboxRootEmits>();
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class")
|
const delegatedProps = reactiveOmit(props, "class", "invalid");
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CheckboxRoot
|
<CheckboxRoot
|
||||||
v-slot="slotProps"
|
v-slot="slotProps"
|
||||||
data-slot="checkbox"
|
data-slot="checkbox"
|
||||||
v-bind="forwarded"
|
:data-invalid="invalid ? '' : undefined"
|
||||||
:class="
|
:aria-invalid="invalid ? 'true' : undefined"
|
||||||
cn('peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
v-bind="forwarded"
|
||||||
props.class)"
|
:class="
|
||||||
>
|
cn(
|
||||||
<CheckboxIndicator
|
'peer border-input bg-card size-5 shrink-0 rounded-md border shadow-xs transition-[background-color,border-color,box-shadow] outline-none hover:border-primary/55 data-[state=checked]:border-brand data-[state=checked]:bg-brand data-[state=checked]:text-brand-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-60 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25',
|
||||||
data-slot="checkbox-indicator"
|
props.class,
|
||||||
class="grid place-content-center text-current transition-none"
|
)
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<slot v-bind="slotProps">
|
<CheckboxIndicator
|
||||||
<Check class="size-3.5" />
|
data-slot="checkbox-indicator"
|
||||||
</slot>
|
class="grid place-content-center text-current transition-none"
|
||||||
</CheckboxIndicator>
|
>
|
||||||
</CheckboxRoot>
|
<slot v-bind="slotProps">
|
||||||
|
<Check class="size-4" stroke-width="2.5" />
|
||||||
|
</slot>
|
||||||
|
</CheckboxIndicator>
|
||||||
|
</CheckboxRoot>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,33 +1,38 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { useVModel } from "@vueuse/core"
|
import { useVModel } from "@vueuse/core";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
defaultValue?: string | number
|
defaultValue?: string | number;
|
||||||
modelValue?: string | number | null
|
modelValue?: string | number | null;
|
||||||
class?: HTMLAttributes["class"]
|
class?: HTMLAttributes["class"];
|
||||||
}>()
|
invalid?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
const emits = defineEmits<{
|
const emits = defineEmits<{
|
||||||
(e: "update:modelValue", payload: string | number): void
|
(e: "update:modelValue", payload: string | number): void;
|
||||||
}>()
|
}>();
|
||||||
|
|
||||||
const modelValue = useVModel(props, "modelValue", emits, {
|
const modelValue = useVModel(props, "modelValue", emits, {
|
||||||
passive: true,
|
passive: true,
|
||||||
defaultValue: props.defaultValue,
|
defaultValue: props.defaultValue,
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<input
|
<input
|
||||||
v-model="modelValue"
|
v-model="modelValue"
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
:class="cn(
|
:data-invalid="invalid ? '' : undefined"
|
||||||
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
:aria-invalid="invalid ? 'true' : undefined"
|
||||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
:class="
|
||||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
cn(
|
||||||
props.class,
|
'border-input bg-card placeholder:text-muted-foreground selection:bg-brand selection:text-brand-foreground min-h-control w-full min-w-0 rounded-md border px-3 py-2 text-base shadow-xs transition-[border-color,box-shadow] outline-none file:mr-3 file:inline-flex file:min-h-8 file:border-0 file:bg-transparent file:text-body file:font-semibold disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-60 md:text-body',
|
||||||
)"
|
'hover:border-primary/45 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35',
|
||||||
>
|
'aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,51 +1,60 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { SelectContentEmits, SelectContentProps } from "reka-ui"
|
import type { SelectContentEmits, SelectContentProps } from "reka-ui";
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
import {
|
import {
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectPortal,
|
SelectPortal,
|
||||||
SelectViewport,
|
SelectViewport,
|
||||||
useForwardPropsEmits,
|
useForwardPropsEmits,
|
||||||
} from "reka-ui"
|
} from "reka-ui";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { SelectScrollDownButton, SelectScrollUpButton } from "."
|
import { SelectScrollDownButton, SelectScrollUpButton } from ".";
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
})
|
});
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
|
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||||
{
|
{
|
||||||
position: "popper",
|
position: "popper",
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
const emits = defineEmits<SelectContentEmits>()
|
const emits = defineEmits<SelectContentEmits>();
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class")
|
const delegatedProps = reactiveOmit(props, "class");
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SelectPortal>
|
<SelectPortal>
|
||||||
<SelectContent
|
<SelectContent
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
v-bind="{ ...$attrs, ...forwarded }"
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
:class="cn(
|
:class="
|
||||||
'bg-popover text-popover-foreground 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 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
|
cn(
|
||||||
position === 'popper'
|
'bg-popover text-popover-foreground border-border 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 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-lg',
|
||||||
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
position === 'popper' &&
|
||||||
props.class,
|
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
)
|
props.class,
|
||||||
"
|
)
|
||||||
>
|
"
|
||||||
<SelectScrollUpButton />
|
>
|
||||||
<SelectViewport :class="cn('p-1', position === 'popper' && 'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1')">
|
<SelectScrollUpButton />
|
||||||
<slot />
|
<SelectViewport
|
||||||
</SelectViewport>
|
:class="
|
||||||
<SelectScrollDownButton />
|
cn(
|
||||||
</SelectContent>
|
'p-1',
|
||||||
</SelectPortal>
|
position === 'popper' &&
|
||||||
|
'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</SelectViewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectContent>
|
||||||
|
</SelectPortal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,44 +1,48 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { SelectItemProps } from "reka-ui"
|
import type { SelectItemProps } from "reka-ui";
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
import { Check } from "lucide-vue-next"
|
import { Check } from "lucide-vue-next";
|
||||||
import {
|
import {
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectItemIndicator,
|
SelectItemIndicator,
|
||||||
SelectItemText,
|
SelectItemText,
|
||||||
useForwardProps,
|
useForwardProps,
|
||||||
} from "reka-ui"
|
} from "reka-ui";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const props = defineProps<SelectItemProps & { class?: HTMLAttributes["class"] }>()
|
const props = defineProps<
|
||||||
|
SelectItemProps & { class?: HTMLAttributes["class"] }
|
||||||
|
>();
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class")
|
const delegatedProps = reactiveOmit(props, "class");
|
||||||
|
|
||||||
const forwardedProps = useForwardProps(delegatedProps)
|
const forwardedProps = useForwardProps(delegatedProps);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SelectItem
|
<SelectItem
|
||||||
data-slot="select-item"
|
data-slot="select-item"
|
||||||
v-bind="forwardedProps"
|
v-bind="forwardedProps"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'focus:bg-accent focus:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2',
|
'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex min-h-9 w-full cursor-default items-center gap-2 rounded-sm py-2 pr-8 pl-2 text-body outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2',
|
||||||
props.class,
|
props.class,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<span class="absolute right-2 flex size-3.5 items-center justify-center">
|
<span
|
||||||
<SelectItemIndicator>
|
class="absolute right-2 flex size-3.5 items-center justify-center"
|
||||||
<slot name="indicator-icon">
|
>
|
||||||
<Check class="size-4" />
|
<SelectItemIndicator>
|
||||||
</slot>
|
<slot name="indicator-icon">
|
||||||
</SelectItemIndicator>
|
<Check class="size-4" />
|
||||||
</span>
|
</slot>
|
||||||
|
</SelectItemIndicator>
|
||||||
|
</span>
|
||||||
|
|
||||||
<SelectItemText>
|
<SelectItemText>
|
||||||
<slot />
|
<slot />
|
||||||
</SelectItemText>
|
</SelectItemText>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,33 +1,45 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { SelectTriggerProps } from "reka-ui"
|
import type { SelectTriggerProps } from "reka-ui";
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
import { ChevronDown } from "lucide-vue-next"
|
import { ChevronDown } from "lucide-vue-next";
|
||||||
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui"
|
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<SelectTriggerProps & { class?: HTMLAttributes["class"], size?: "sm" | "default" }>(),
|
defineProps<
|
||||||
{ size: "default" },
|
SelectTriggerProps & {
|
||||||
)
|
class?: HTMLAttributes["class"];
|
||||||
|
size?: "sm" | "default";
|
||||||
|
invalid?: boolean;
|
||||||
|
}
|
||||||
|
>(),
|
||||||
|
{ size: "default", invalid: false },
|
||||||
|
);
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class", "size")
|
const delegatedProps = reactiveOmit(props, "class", "size", "invalid");
|
||||||
const forwardedProps = useForwardProps(delegatedProps)
|
const forwardedProps = useForwardProps(delegatedProps);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
:data-size="size"
|
:data-size="size"
|
||||||
v-bind="forwardedProps"
|
:data-invalid="invalid ? '' : undefined"
|
||||||
:class="cn(
|
:aria-invalid="invalid ? 'true' : undefined"
|
||||||
'border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
v-bind="forwardedProps"
|
||||||
props.class,
|
:class="
|
||||||
)"
|
cn(
|
||||||
>
|
'group/select-trigger border-input bg-card data-[placeholder]:text-muted-foreground flex w-fit items-center justify-between gap-2 rounded-md border px-3 py-2 text-body whitespace-nowrap shadow-xs transition-[border-color,box-shadow] outline-none hover:border-primary/45 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-60 data-[state=open]:border-primary data-[size=default]:min-h-control data-[size=sm]:min-h-9 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 [&_svg:not([class*=\'text-\'])]:text-muted-foreground',
|
||||||
<slot />
|
props.class,
|
||||||
<SelectIcon as-child>
|
)
|
||||||
<ChevronDown class="size-4 opacity-50" />
|
"
|
||||||
</SelectIcon>
|
>
|
||||||
</SelectTrigger>
|
<slot />
|
||||||
|
<SelectIcon as-child>
|
||||||
|
<ChevronDown
|
||||||
|
class="size-4 transition-transform duration-150 group-data-[state=open]/select-trigger:rotate-180"
|
||||||
|
/>
|
||||||
|
</SelectIcon>
|
||||||
|
</SelectTrigger>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { Loader2Icon } from "lucide-vue-next"
|
import { Loader2Icon } from "lucide-vue-next";
|
||||||
import { cn } from "@/lib/utils"
|
import { computed } from "vue";
|
||||||
|
import { useLocalization } from "@/composables/useLocalization";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
class?: HTMLAttributes["class"]
|
class?: HTMLAttributes["class"];
|
||||||
}>()
|
label?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const accessibleLabel = computed(() => props.label ?? t("common.loading"));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Loader2Icon
|
<Loader2Icon
|
||||||
role="status"
|
role="status"
|
||||||
aria-label="Loading"
|
:aria-label="accessibleLabel"
|
||||||
:class="cn('size-4 animate-spin', props.class)"
|
:class="cn('size-4 animate-spin', props.class)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class UiComponentsTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_foundational_ui_component_behaviour(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/ui-components.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('UI component behavior checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { createApp, h, ref } from 'vue';
|
||||||
|
import '../../resources/css/app.css';
|
||||||
|
import AppearanceTabs from '../../resources/js/components/AppearanceTabs.vue';
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
} from '../../resources/js/components/ui/avatar';
|
||||||
|
import { Badge } from '../../resources/js/components/ui/badge';
|
||||||
|
import { Button } from '../../resources/js/components/ui/button';
|
||||||
|
import { Checkbox } from '../../resources/js/components/ui/checkbox';
|
||||||
|
import { Input } from '../../resources/js/components/ui/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '../../resources/js/components/ui/select';
|
||||||
|
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||||
|
import { createLocalization } from '../../resources/js/lib/localization';
|
||||||
|
|
||||||
|
const Demo = {
|
||||||
|
setup() {
|
||||||
|
const selectedStatus = ref('review');
|
||||||
|
const subscribed = ref(true);
|
||||||
|
|
||||||
|
return () =>
|
||||||
|
h('main', { class: 'mx-auto grid max-w-4xl gap-column p-page' }, [
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
class: 'flex flex-wrap items-center justify-between gap-stack',
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h('div', {}, [
|
||||||
|
h(
|
||||||
|
'h1',
|
||||||
|
{ class: 'text-headline' },
|
||||||
|
'Foundational UI components',
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'p',
|
||||||
|
{ class: 'text-muted-foreground' },
|
||||||
|
'Fluid Emerald states and controls',
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
h(AppearanceTabs),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
h('section', { class: 'grid gap-column sm:grid-cols-2' }, [
|
||||||
|
h(
|
||||||
|
'article',
|
||||||
|
{
|
||||||
|
class: 'grid content-start gap-column rounded-md border bg-card p-card',
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h(
|
||||||
|
'h2',
|
||||||
|
{ class: 'text-title' },
|
||||||
|
'Buttons and badges',
|
||||||
|
),
|
||||||
|
h('div', { class: 'flex flex-wrap gap-stack' }, [
|
||||||
|
h(Button, {}, () => 'Primary'),
|
||||||
|
h(
|
||||||
|
Button,
|
||||||
|
{ variant: 'secondary' },
|
||||||
|
() => 'Secondary',
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
Button,
|
||||||
|
{ variant: 'outline' },
|
||||||
|
() => 'Outline',
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
Button,
|
||||||
|
{ variant: 'destructive' },
|
||||||
|
() => 'Delete',
|
||||||
|
),
|
||||||
|
h(Button, { disabled: true }, () => 'Disabled'),
|
||||||
|
h(Button, { loading: true }, () => 'Saving'),
|
||||||
|
]),
|
||||||
|
h('div', { class: 'flex flex-wrap gap-stack' }, [
|
||||||
|
h(Badge, {}, () => 'Active'),
|
||||||
|
h(Badge, { variant: 'success' }, () => 'Done'),
|
||||||
|
h(
|
||||||
|
Badge,
|
||||||
|
{ variant: 'warning' },
|
||||||
|
() => 'Medium',
|
||||||
|
),
|
||||||
|
h(Badge, { variant: 'info' }, () => 'Low'),
|
||||||
|
h(
|
||||||
|
Badge,
|
||||||
|
{ variant: 'destructive' },
|
||||||
|
() => 'High',
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
Badge,
|
||||||
|
{ variant: 'outline' },
|
||||||
|
() => 'KAN-18',
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'article',
|
||||||
|
{
|
||||||
|
class: 'grid content-start gap-column rounded-md border bg-card p-card',
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h(
|
||||||
|
'h2',
|
||||||
|
{ class: 'text-title' },
|
||||||
|
'Inputs and selection',
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'label',
|
||||||
|
{ class: 'grid gap-stack', for: 'title' },
|
||||||
|
[
|
||||||
|
h(
|
||||||
|
'span',
|
||||||
|
{ class: 'text-body font-medium' },
|
||||||
|
'Title',
|
||||||
|
),
|
||||||
|
h(Input, {
|
||||||
|
id: 'title',
|
||||||
|
placeholder: 'Ticket title',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'label',
|
||||||
|
{
|
||||||
|
class: 'grid gap-stack',
|
||||||
|
for: 'invalid-title',
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h(
|
||||||
|
'span',
|
||||||
|
{ class: 'text-body font-medium' },
|
||||||
|
'Invalid title',
|
||||||
|
),
|
||||||
|
h(Input, {
|
||||||
|
id: 'invalid-title',
|
||||||
|
invalid: true,
|
||||||
|
modelValue: 'Needs attention',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'label',
|
||||||
|
{ class: 'grid gap-stack', for: 'status' },
|
||||||
|
[
|
||||||
|
h(
|
||||||
|
'span',
|
||||||
|
{ class: 'text-body font-medium' },
|
||||||
|
'Status',
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
Select,
|
||||||
|
{
|
||||||
|
modelValue: selectedStatus.value,
|
||||||
|
'onUpdate:modelValue': (value) => {
|
||||||
|
selectedStatus.value = value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
default: () => [
|
||||||
|
h(
|
||||||
|
SelectTrigger,
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
class: 'w-full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
default: () =>
|
||||||
|
h(SelectValue, {
|
||||||
|
placeholder:
|
||||||
|
'Choose a status',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
h(SelectContent, {}, () => [
|
||||||
|
h(
|
||||||
|
SelectItem,
|
||||||
|
{ value: 'todo' },
|
||||||
|
() => 'To do',
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
SelectItem,
|
||||||
|
{ value: 'review' },
|
||||||
|
() => 'Review',
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
SelectItem,
|
||||||
|
{ value: 'done' },
|
||||||
|
() => 'Done',
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'label',
|
||||||
|
{ class: 'flex items-center gap-stack' },
|
||||||
|
[
|
||||||
|
h(Checkbox, {
|
||||||
|
modelValue: subscribed.value,
|
||||||
|
'onUpdate:modelValue': (value) => {
|
||||||
|
subscribed.value = Boolean(value);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
h('span', {}, 'Notify me about changes'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
h(
|
||||||
|
'section',
|
||||||
|
{
|
||||||
|
class: 'flex items-center gap-stack rounded-md border bg-card p-card',
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h(Avatar, { size: 'sm' }, () =>
|
||||||
|
h(AvatarFallback, {}, () => 'NS'),
|
||||||
|
),
|
||||||
|
h(Avatar, {}, () => h(AvatarFallback, {}, () => 'AK')),
|
||||||
|
h(Avatar, { size: 'lg' }, () =>
|
||||||
|
h(AvatarFallback, {}, () => 'LT'),
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'span',
|
||||||
|
{ class: 'text-muted-foreground' },
|
||||||
|
'24px, 32px and 40px avatars',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
createApp(Demo)
|
||||||
|
.use(createLocalization())
|
||||||
|
.use(createAppearance('light'))
|
||||||
|
.mount('#app');
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-appearance="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>UI component preview</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script
|
||||||
|
type="module"
|
||||||
|
src="/tests/Fixtures/ui-components-preview.mjs"
|
||||||
|
></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import { createSSRApp, h } from 'vue';
|
||||||
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: { middlewareMode: true, hmr: false, watch: null },
|
||||||
|
appType: 'custom',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { createLocalization } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/localization.ts',
|
||||||
|
);
|
||||||
|
const { en } = await server.ssrLoadModule('/resources/js/locales/en.ts');
|
||||||
|
const { default: Button } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/ui/button/Button.vue',
|
||||||
|
);
|
||||||
|
const { buttonVariants } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/ui/button/index.ts',
|
||||||
|
);
|
||||||
|
const { default: Input } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/ui/input/Input.vue',
|
||||||
|
);
|
||||||
|
const { default: Select } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/ui/select/Select.vue',
|
||||||
|
);
|
||||||
|
const { default: SelectTrigger } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/ui/select/SelectTrigger.vue',
|
||||||
|
);
|
||||||
|
const { default: SelectValue } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/ui/select/SelectValue.vue',
|
||||||
|
);
|
||||||
|
const { default: Checkbox } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/ui/checkbox/Checkbox.vue',
|
||||||
|
);
|
||||||
|
const { default: Badge } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/ui/badge/Badge.vue',
|
||||||
|
);
|
||||||
|
const { badgeVariants } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/ui/badge/index.ts',
|
||||||
|
);
|
||||||
|
const { default: Avatar } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/ui/avatar/Avatar.vue',
|
||||||
|
);
|
||||||
|
const { default: AvatarFallback } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/ui/avatar/AvatarFallback.vue',
|
||||||
|
);
|
||||||
|
const { default: UserAvatar } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/ui/avatar/UserAvatar.vue',
|
||||||
|
);
|
||||||
|
const { avatarVariants } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/ui/avatar/index.ts',
|
||||||
|
);
|
||||||
|
|
||||||
|
const german = createLocalization(
|
||||||
|
{ locale: 'de' },
|
||||||
|
{ de: { ...en, 'common.loading': 'Wird geladen…' } },
|
||||||
|
);
|
||||||
|
const render = (component, props = {}, slots = {}) =>
|
||||||
|
renderToString(
|
||||||
|
createSSRApp({ render: () => h(component, props, slots) }).use(
|
||||||
|
german,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadingButton = await render(
|
||||||
|
Button,
|
||||||
|
{ loading: true },
|
||||||
|
{ default: () => 'Save' },
|
||||||
|
);
|
||||||
|
assert.match(loadingButton, /<button/);
|
||||||
|
assert.match(loadingButton, /disabled/);
|
||||||
|
assert.match(loadingButton, /aria-disabled="true"/);
|
||||||
|
assert.match(loadingButton, /aria-busy="true"/);
|
||||||
|
assert.match(loadingButton, /data-loading/);
|
||||||
|
assert.match(loadingButton, /role="status"/);
|
||||||
|
assert.match(loadingButton, /aria-label="Wird geladen…"/);
|
||||||
|
assert.match(loadingButton, />Save</);
|
||||||
|
assert.match(buttonVariants(), /min-h-control/);
|
||||||
|
assert.match(buttonVariants(), /bg-brand/);
|
||||||
|
assert.match(buttonVariants({ variant: 'outline' }), /bg-card/);
|
||||||
|
|
||||||
|
const invalidInput = await render(Input, {
|
||||||
|
disabled: true,
|
||||||
|
invalid: true,
|
||||||
|
name: 'title',
|
||||||
|
});
|
||||||
|
assert.match(invalidInput, /<input/);
|
||||||
|
assert.match(invalidInput, /name="title"/);
|
||||||
|
assert.match(invalidInput, /disabled/);
|
||||||
|
assert.match(invalidInput, /aria-invalid="true"/);
|
||||||
|
assert.match(invalidInput, /data-invalid/);
|
||||||
|
assert.match(invalidInput, /min-h-control/);
|
||||||
|
|
||||||
|
const select = await render(
|
||||||
|
Select,
|
||||||
|
{ disabled: true },
|
||||||
|
{
|
||||||
|
default: () =>
|
||||||
|
h(
|
||||||
|
SelectTrigger,
|
||||||
|
{ invalid: true },
|
||||||
|
{
|
||||||
|
default: () =>
|
||||||
|
h(SelectValue, { placeholder: 'Choose' }),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.match(select, /role="combobox"/);
|
||||||
|
assert.match(select, /disabled/);
|
||||||
|
assert.match(select, /aria-invalid="true"/);
|
||||||
|
assert.match(select, /data-invalid/);
|
||||||
|
assert.match(select, /min-h-control/);
|
||||||
|
|
||||||
|
const checkbox = await render(Checkbox, {
|
||||||
|
disabled: true,
|
||||||
|
invalid: true,
|
||||||
|
});
|
||||||
|
assert.match(checkbox, /role="checkbox"/);
|
||||||
|
assert.match(checkbox, /aria-checked="false"/);
|
||||||
|
assert.match(checkbox, /disabled/);
|
||||||
|
assert.match(checkbox, /aria-invalid="true"/);
|
||||||
|
assert.match(checkbox, /data-invalid/);
|
||||||
|
|
||||||
|
const successBadge = await render(
|
||||||
|
Badge,
|
||||||
|
{ variant: 'success' },
|
||||||
|
{ default: () => 'Done' },
|
||||||
|
);
|
||||||
|
assert.match(successBadge, /bg-success/);
|
||||||
|
assert.match(successBadge, />Done</);
|
||||||
|
assert.doesNotMatch(successBadge, /variant="success"/);
|
||||||
|
assert.match(badgeVariants({ variant: 'success' }), /bg-success/);
|
||||||
|
assert.match(badgeVariants({ variant: 'warning' }), /bg-warning/);
|
||||||
|
assert.match(badgeVariants({ variant: 'info' }), /bg-info/);
|
||||||
|
|
||||||
|
const avatar = await render(
|
||||||
|
Avatar,
|
||||||
|
{ size: 'xl', shape: 'square' },
|
||||||
|
{ default: () => h(AvatarFallback, {}, () => 'NS') },
|
||||||
|
);
|
||||||
|
assert.match(avatar, /data-size="xl"/);
|
||||||
|
assert.match(avatar, /data-shape="square"/);
|
||||||
|
assert.match(avatar, /size-16/);
|
||||||
|
assert.match(avatar, /rounded-sm/);
|
||||||
|
assert.match(avatar, />NS</);
|
||||||
|
assert.match(avatarVariants({ size: 'sm' }), /size-6/);
|
||||||
|
|
||||||
|
const userAvatar = await render(UserAvatar, {
|
||||||
|
rounded: true,
|
||||||
|
size: 'lg',
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Nico Schröder',
|
||||||
|
email: 'nico@example.test',
|
||||||
|
avatar: null,
|
||||||
|
avatar_color: null,
|
||||||
|
avatar_is_generated: false,
|
||||||
|
email_verified_at: null,
|
||||||
|
must_change_password_on_next_login: false,
|
||||||
|
created_at: '2026-09-09T00:00:00Z',
|
||||||
|
updated_at: '2026-09-09T00:00:00Z',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.match(userAvatar, /data-size="lg"/);
|
||||||
|
assert.match(userAvatar, /data-shape="circle"/);
|
||||||
|
assert.match(userAvatar, /aria-label="Nico Schröder"/);
|
||||||
|
assert.match(userAvatar, />NS</);
|
||||||
|
|
||||||
|
console.log('UI component behavior checks passed.');
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user