From 2e2ddd5b740ffa373474a2e574b959f64a8c6354 Mon Sep 17 00:00:00 2001 From: Twyco Date: Wed, 9 Sep 2026 15:31:04 +0200 Subject: [PATCH] feat(KAN-18): redesign foundational UI components --- resources/js/components/ui/avatar/Avatar.vue | 41 ++- .../components/ui/avatar/AvatarFallback.vue | 35 ++- .../js/components/ui/avatar/UserAvatar.vue | 48 ++-- resources/js/components/ui/avatar/index.ts | 34 ++- resources/js/components/ui/badge/Badge.vue | 40 +-- resources/js/components/ui/badge/index.ts | 49 ++-- resources/js/components/ui/button/Button.vue | 56 ++-- resources/js/components/ui/button/index.ts | 67 +++-- .../js/components/ui/checkbox/Checkbox.vue | 65 +++-- resources/js/components/ui/input/Input.vue | 49 ++-- .../js/components/ui/select/SelectContent.vue | 87 +++--- .../js/components/ui/select/SelectItem.vue | 72 ++--- .../js/components/ui/select/SelectTrigger.vue | 62 +++-- .../js/components/ui/spinner/Spinner.vue | 26 +- tests/Feature/UiComponentsTest.php | 19 ++ tests/Fixtures/ui-components-preview.mjs | 247 ++++++++++++++++++ tests/Fixtures/ui-components.html | 15 ++ tests/Fixtures/ui-components.mjs | 184 +++++++++++++ 18 files changed, 892 insertions(+), 304 deletions(-) create mode 100644 tests/Feature/UiComponentsTest.php create mode 100644 tests/Fixtures/ui-components-preview.mjs create mode 100644 tests/Fixtures/ui-components.html create mode 100644 tests/Fixtures/ui-components.mjs diff --git a/resources/js/components/ui/avatar/Avatar.vue b/resources/js/components/ui/avatar/Avatar.vue index bb7e669..9343bb4 100644 --- a/resources/js/components/ui/avatar/Avatar.vue +++ b/resources/js/components/ui/avatar/Avatar.vue @@ -1,18 +1,35 @@ diff --git a/resources/js/components/ui/avatar/AvatarFallback.vue b/resources/js/components/ui/avatar/AvatarFallback.vue index 16b588a..4683221 100644 --- a/resources/js/components/ui/avatar/AvatarFallback.vue +++ b/resources/js/components/ui/avatar/AvatarFallback.vue @@ -1,21 +1,28 @@ diff --git a/resources/js/components/ui/avatar/UserAvatar.vue b/resources/js/components/ui/avatar/UserAvatar.vue index 3412c64..a2b89f7 100644 --- a/resources/js/components/ui/avatar/UserAvatar.vue +++ b/resources/js/components/ui/avatar/UserAvatar.vue @@ -1,41 +1,39 @@ diff --git a/resources/js/components/ui/avatar/index.ts b/resources/js/components/ui/avatar/index.ts index cf0e003..940c101 100644 --- a/resources/js/components/ui/avatar/index.ts +++ b/resources/js/components/ui/avatar/index.ts @@ -1,3 +1,31 @@ -export { default as Avatar } from "./Avatar.vue" -export { default as AvatarFallback } from "./AvatarFallback.vue" -export { default as AvatarImage } from "./AvatarImage.vue" +import type { VariantProps } from "class-variance-authority"; +import { cva } from "class-variance-authority"; + +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; diff --git a/resources/js/components/ui/badge/Badge.vue b/resources/js/components/ui/badge/Badge.vue index d894dfe..0a3c879 100644 --- a/resources/js/components/ui/badge/Badge.vue +++ b/resources/js/components/ui/badge/Badge.vue @@ -1,26 +1,28 @@ diff --git a/resources/js/components/ui/badge/index.ts b/resources/js/components/ui/badge/index.ts index bbc0dfa..d1b532e 100644 --- a/resources/js/components/ui/badge/index.ts +++ b/resources/js/components/ui/badge/index.ts @@ -1,26 +1,31 @@ -import type { VariantProps } from "class-variance-authority" -import { cva } from "class-variance-authority" +import type { VariantProps } 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( - "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", - { - variants: { - variant: { - default: - "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", - secondary: - "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", - 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", - outline: - "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", - }, + "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: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/35", + outline: + "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 +); +export type BadgeVariants = VariantProps; diff --git a/resources/js/components/ui/button/Button.vue b/resources/js/components/ui/button/Button.vue index 374320b..ed0d99c 100644 --- a/resources/js/components/ui/button/Button.vue +++ b/resources/js/components/ui/button/Button.vue @@ -1,29 +1,47 @@ diff --git a/resources/js/components/ui/button/index.ts b/resources/js/components/ui/button/index.ts index 7cba0b4..4f8a2d2 100644 --- a/resources/js/components/ui/button/index.ts +++ b/resources/js/components/ui/button/index.ts @@ -1,38 +1,37 @@ -import type { VariantProps } from "class-variance-authority" -import { cva } from "class-variance-authority" +import type { VariantProps } 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( - "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", - { - variants: { - variant: { - default: - "bg-primary text-primary-foreground hover:bg-primary/90", - destructive: - "bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40", - 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", - secondary: - "bg-secondary text-secondary-foreground hover:bg-secondary/80", - ghost: - "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - "default": "h-9 px-4 py-2 has-[>svg]:px-3", - "sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", - "lg": "h-10 rounded-md px-6 has-[>svg]:px-4", - "icon": "size-9", - "icon-sm": "size-8", - "icon-lg": "size-10", - }, + "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: { + variant: { + default: + "border border-transparent bg-brand text-brand-foreground shadow-xs hover:brightness-95", + destructive: + "border border-transparent bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/35", + outline: + "border border-input bg-card text-foreground shadow-xs hover:border-primary/45 hover:bg-accent hover:text-accent-foreground", + secondary: + "border border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "border border-transparent hover:bg-accent hover:text-accent-foreground", + link: "min-h-0 border-0 px-0 text-primary underline-offset-4 shadow-none hover:underline", + }, + size: { + default: "min-h-control 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", + lg: "min-h-12 px-6 py-3 has-[>svg]:px-4", + icon: "size-control", + "icon-sm": "size-9", + "icon-lg": "size-12", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, }, - defaultVariants: { - variant: "default", - size: "default", - }, - }, -) -export type ButtonVariants = VariantProps +); +export type ButtonVariants = VariantProps; diff --git a/resources/js/components/ui/checkbox/Checkbox.vue b/resources/js/components/ui/checkbox/Checkbox.vue index 6604cbd..fabdc5a 100644 --- a/resources/js/components/ui/checkbox/Checkbox.vue +++ b/resources/js/components/ui/checkbox/Checkbox.vue @@ -1,35 +1,48 @@ diff --git a/resources/js/components/ui/input/Input.vue b/resources/js/components/ui/input/Input.vue index e6486c7..60dfaad 100644 --- a/resources/js/components/ui/input/Input.vue +++ b/resources/js/components/ui/input/Input.vue @@ -1,33 +1,38 @@ diff --git a/resources/js/components/ui/select/SelectContent.vue b/resources/js/components/ui/select/SelectContent.vue index adf04ec..dd16dbf 100644 --- a/resources/js/components/ui/select/SelectContent.vue +++ b/resources/js/components/ui/select/SelectContent.vue @@ -1,51 +1,60 @@ diff --git a/resources/js/components/ui/select/SelectItem.vue b/resources/js/components/ui/select/SelectItem.vue index 9371764..01237ce 100644 --- a/resources/js/components/ui/select/SelectItem.vue +++ b/resources/js/components/ui/select/SelectItem.vue @@ -1,44 +1,48 @@ diff --git a/resources/js/components/ui/select/SelectTrigger.vue b/resources/js/components/ui/select/SelectTrigger.vue index 667908b..dc738b9 100644 --- a/resources/js/components/ui/select/SelectTrigger.vue +++ b/resources/js/components/ui/select/SelectTrigger.vue @@ -1,33 +1,45 @@ diff --git a/resources/js/components/ui/spinner/Spinner.vue b/resources/js/components/ui/spinner/Spinner.vue index 57cd1a9..191889f 100644 --- a/resources/js/components/ui/spinner/Spinner.vue +++ b/resources/js/components/ui/spinner/Spinner.vue @@ -1,17 +1,23 @@ diff --git a/tests/Feature/UiComponentsTest.php b/tests/Feature/UiComponentsTest.php new file mode 100644 index 0000000..a5c4088 --- /dev/null +++ b/tests/Feature/UiComponentsTest.php @@ -0,0 +1,19 @@ +setTimeout(60); + $process->run(); + + $this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput()); + $this->assertStringContainsString('UI component behavior checks passed.', $process->getOutput()); + } +} diff --git a/tests/Fixtures/ui-components-preview.mjs b/tests/Fixtures/ui-components-preview.mjs new file mode 100644 index 0000000..04ba3bd --- /dev/null +++ b/tests/Fixtures/ui-components-preview.mjs @@ -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'); diff --git a/tests/Fixtures/ui-components.html b/tests/Fixtures/ui-components.html new file mode 100644 index 0000000..012c274 --- /dev/null +++ b/tests/Fixtures/ui-components.html @@ -0,0 +1,15 @@ + + + + + + UI component preview + + +
+ + + diff --git a/tests/Fixtures/ui-components.mjs b/tests/Fixtures/ui-components.mjs new file mode 100644 index 0000000..aaa13da --- /dev/null +++ b/tests/Fixtures/ui-components.mjs @@ -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, /