feat(KAN-18): redesign foundational UI components
This commit is contained in:
@@ -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