feat(KAN-18): redesign foundational UI components
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 6s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 6s

This commit is contained in:
2026-09-11 14:13:49 +02:00
parent c58196293f
commit 2e2ddd5b74
18 changed files with 892 additions and 304 deletions
+19
View File
@@ -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());
}
}
+247
View File
@@ -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');
+15
View File
@@ -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>
+184
View File
@@ -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();
}