phase 2 - ui refactor
This commit is contained in:
parent
2510277907
commit
8b181087c3
@ -1,9 +1,11 @@
|
||||
// @ts-nocheck
|
||||
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
|
||||
import storybook from "eslint-plugin-storybook";
|
||||
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import pluginTailwindcss from 'eslint-plugin-tailwindcss'
|
||||
import pluginVitest from '@vitest/eslint-plugin'
|
||||
import pluginPlaywright from 'eslint-plugin-playwright'
|
||||
import pluginOxlint from 'eslint-plugin-oxlint'
|
||||
@ -36,4 +38,5 @@ export default defineConfigWithVueTs(
|
||||
},
|
||||
...pluginOxlint.configs['flat/recommended'],
|
||||
skipFormatting,
|
||||
pluginTailwindcss.configs.recommended,
|
||||
)
|
||||
|
1615
fe/package-lock.json
generated
1615
fe/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -10,7 +10,7 @@
|
||||
"test:unit": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"lint:oxlint": "oxlint . --fix -D correctness --ignore-path .gitignore",
|
||||
"lint:eslint": "eslint . --fix",
|
||||
"lint": "run-s lint:*",
|
||||
@ -19,18 +19,15 @@
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/vue": "^1.7.23",
|
||||
"@iconify/vue": "^4.1.1",
|
||||
"@sentry/tracing": "^7.120.3",
|
||||
"@sentry/vue": "^7.120.3",
|
||||
"@supabase/auth-js": "^2.69.1",
|
||||
"@supabase/supabase-js": "^2.49.4",
|
||||
"@vueuse/core": "^13.1.0",
|
||||
"axios": "^1.9.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^12.16.0",
|
||||
"motion": "^12.15.0",
|
||||
"pinia": "^3.0.2",
|
||||
"qs": "^6.14.0",
|
||||
"reka-ui": "^2.3.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^9.9.1",
|
||||
"vue-router": "^4.5.1",
|
||||
@ -38,11 +35,11 @@
|
||||
"workbox-background-sync": "^7.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@headlessui/tailwindcss": "^0.2.2",
|
||||
"@intlify/unplugin-vue-i18n": "^4.0.0",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@storybook/addon-docs": "^9.0.2",
|
||||
"@storybook/addon-onboarding": "^9.0.2",
|
||||
"@storybook/vue3-vite": "^9.0.2",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@tsconfig/node22": "^22.0.1",
|
||||
"@types/date-fns": "^2.5.3",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
@ -54,18 +51,21 @@
|
||||
"@vue/eslint-config-typescript": "^14.5.0",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^9.26.0",
|
||||
"eslint-plugin-oxlint": "^0.16.0",
|
||||
"eslint-plugin-playwright": "^2.2.0",
|
||||
"eslint-plugin-storybook": "^9.0.2",
|
||||
"eslint-plugin-tailwindcss": "^3.13.0",
|
||||
"eslint-plugin-vue": "~10.0.0",
|
||||
"jiti": "^2.4.2",
|
||||
"jsdom": "^26.0.0",
|
||||
"npm-run-all2": "^7.0.2",
|
||||
"oxlint": "^0.16.0",
|
||||
"postcss": "^8.4.34",
|
||||
"prettier": "^3.5.3",
|
||||
"sass": "^1.88.0",
|
||||
"storybook": "^9.0.2",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"typescript": "~5.8.0",
|
||||
"vite": "^6.2.4",
|
||||
"vite-plugin-pwa": "^1.0.0",
|
||||
|
6
fe/postcss.config.cjs
Normal file
6
fe/postcss.config.cjs
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
@ -10,21 +10,3 @@ import NotificationDisplay from '@/components/global/NotificationDisplay.vue';
|
||||
// const offlineStore = useOfflineStore();
|
||||
// offlineStore.init(); // If you move init logic here
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Avenir, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
color: #2c3e50;
|
||||
background-color: #f0f2f5;
|
||||
/* Example background */
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
9
fe/src/assets/base.css
Normal file
9
fe/src/assets/base.css
Normal file
@ -0,0 +1,9 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-light text-dark font-hand antialiased;
|
||||
}
|
||||
}
|
@ -1,96 +0,0 @@
|
||||
// src/assets/main.scss
|
||||
// @import './variables.scss'; // Your custom variables
|
||||
@use './valerie-ui.scss';
|
||||
|
||||
// Example global styles
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
margin: 0;
|
||||
background-color: var(--bg-color-page, #f4f4f8);
|
||||
color: var(--text-color, #333);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
// Offline UI styles
|
||||
.offline-item {
|
||||
position: relative;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8'/%3E%3Cpath d='M3 3v5h5'/%3E%3Cpath d='M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16'/%3E%3Cpath d='M16 21h5v-5'/%3E%3C/svg%3E");
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
&.synced {
|
||||
opacity: 1;
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// Disabled offline features
|
||||
.feature-offline-disabled {
|
||||
position: relative;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
|
||||
&::before {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.5rem;
|
||||
background-color: var(--bg-color-tooltip, #333);
|
||||
color: white;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
&:hover::before {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
// Add more global utility classes or base styles
|
File diff suppressed because it is too large
Load Diff
@ -1,21 +0,0 @@
|
||||
// src/assets/variables.scss
|
||||
:root {
|
||||
--primary-color: #1976D2;
|
||||
--secondary-color: #26A69A;
|
||||
--accent-color: #9C27B0;
|
||||
|
||||
--dark-color: #1D1D1D;
|
||||
--dark-page-color: #121212;
|
||||
|
||||
--positive-color: #21BA45;
|
||||
--negative-color: #C10015;
|
||||
--info-color: #31CCEC;
|
||||
--warning-color: #F2C037;
|
||||
|
||||
--text-color: #333;
|
||||
--bg-color: #fff;
|
||||
--bg-color-page: #f0f2f5;
|
||||
|
||||
--header-height: 56px;
|
||||
--footer-height: 56px;
|
||||
}
|
13
fe/src/components/BaseIcon.vue
Normal file
13
fe/src/components/BaseIcon.vue
Normal file
@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<Icon :icon="name" v-bind="$attrs" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
|
||||
interface Props {
|
||||
name: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
@ -29,19 +29,20 @@
|
||||
<div class="neo-item-actions">
|
||||
<button class="btn btn-sm btn-neutral" @click="toggleTimer"
|
||||
:disabled="chore.is_completed || !chore.current_assignment_id">
|
||||
{{ isActiveTimer ? '⏸️' : '▶️' }}
|
||||
<BaseIcon :name="isActiveTimer ? 'heroicons:pause-20-solid' : 'heroicons:play-20-solid'"
|
||||
class="w-4 h-4" />
|
||||
</button>
|
||||
<button class="btn btn-sm btn-neutral" @click="emit('open-details', chore)" title="View Details">
|
||||
📋
|
||||
<BaseIcon name="heroicons:clipboard-document-list-20-solid" class="w-4 h-4" />
|
||||
</button>
|
||||
<button class="btn btn-sm btn-neutral" @click="emit('open-history', chore)" title="View History">
|
||||
📅
|
||||
<BaseIcon name="heroicons:calendar-days-20-solid" class="w-4 h-4" />
|
||||
</button>
|
||||
<button class="btn btn-sm btn-neutral" @click="emit('edit', chore)">
|
||||
✏️
|
||||
<BaseIcon name="heroicons:pencil-square-20-solid" class="w-4 h-4" />
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" @click="emit('delete', chore)">
|
||||
🗑️
|
||||
<BaseIcon name="heroicons:trash-20-solid" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -62,6 +63,7 @@ import { formatDistanceToNow, parseISO, isToday, isPast } from 'date-fns';
|
||||
import type { ChoreWithCompletion } from '../types/chore';
|
||||
import type { TimeEntry } from '../stores/timeEntryStore';
|
||||
import { formatDuration } from '../utils/formatters';
|
||||
import BaseIcon from './BaseIcon.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
chore: ChoreWithCompletion;
|
||||
|
73
fe/src/components/ui/Button.vue
Normal file
73
fe/src/components/ui/Button.vue
Normal file
@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<button :type="type" :class="buttonClasses" :disabled="disabled">
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
export interface ButtonProps {
|
||||
/** Visual variant */
|
||||
variant?: 'solid' | 'outline' | 'ghost'
|
||||
/** Color token */
|
||||
color?: 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'neutral'
|
||||
/** Size preset */
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
/** HTML button type */
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
/** Disabled state */
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ButtonProps>(), {
|
||||
variant: 'solid',
|
||||
color: 'primary',
|
||||
size: 'md',
|
||||
type: 'button',
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
/**
|
||||
* Tailwind class maps – keep in sync with design tokens in `tailwind.config.ts`.
|
||||
*/
|
||||
const base =
|
||||
'inline-flex items-center justify-center font-medium rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 transition disabled:opacity-50 disabled:pointer-events-none'
|
||||
|
||||
const sizeClasses: Record<string, string> = {
|
||||
sm: 'px-2.5 py-1.5 text-xs',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
}
|
||||
|
||||
const colorMatrix: Record<string, Record<string, string>> = {
|
||||
solid: {
|
||||
primary: 'bg-primary text-white hover:bg-primary/90',
|
||||
secondary: 'bg-secondary text-white hover:bg-secondary/90',
|
||||
success: 'bg-success text-white hover:bg-success/90',
|
||||
warning: 'bg-warning text-white hover:bg-warning/90',
|
||||
danger: 'bg-danger text-white hover:bg-danger/90',
|
||||
neutral: 'bg-neutral text-white hover:bg-neutral/90',
|
||||
},
|
||||
outline: {
|
||||
primary: 'border border-primary text-primary hover:bg-primary/10',
|
||||
secondary: 'border border-secondary text-secondary hover:bg-secondary/10',
|
||||
success: 'border border-success text-success hover:bg-success/10',
|
||||
warning: 'border border-warning text-warning hover:bg-warning/10',
|
||||
danger: 'border border-danger text-danger hover:bg-danger/10',
|
||||
neutral: 'border border-neutral text-neutral hover:bg-neutral/10',
|
||||
},
|
||||
ghost: {
|
||||
primary: 'text-primary hover:bg-primary/10',
|
||||
secondary: 'text-secondary hover:bg-secondary/10',
|
||||
success: 'text-success hover:bg-success/10',
|
||||
warning: 'text-warning hover:bg-warning/10',
|
||||
danger: 'text-danger hover:bg-danger/10',
|
||||
neutral: 'text-neutral hover:bg-neutral/10',
|
||||
},
|
||||
}
|
||||
|
||||
const buttonClasses = computed(() => {
|
||||
return [base, sizeClasses[props.size], colorMatrix[props.variant][props.color]].join(' ')
|
||||
})
|
||||
</script>
|
36
fe/src/components/ui/Dialog.vue
Normal file
36
fe/src/components/ui/Dialog.vue
Normal file
@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<TransitionRoot as="template" :show="modelValue">
|
||||
<Dialog as="div" class="fixed inset-0 z-50 overflow-y-auto" @close="emitClose">
|
||||
<div class="flex min-h-screen items-center justify-center px-4 py-8 text-center sm:block sm:p-0">
|
||||
<!-- Backdrop -->
|
||||
<TransitionChild as="template" enter="ease-out duration-300" enter-from="opacity-0"
|
||||
enter-to="opacity-100" leave="ease-in duration-200" leave-from="opacity-100" leave-to="opacity-0">
|
||||
<DialogOverlay class="fixed inset-0 bg-black/40 ui-not-focusable" />
|
||||
</TransitionChild>
|
||||
|
||||
<!-- Panel -->
|
||||
<TransitionChild as="template" enter="ease-out duration-300"
|
||||
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enter-to="opacity-100 translate-y-0 sm:scale-100" leave="ease-in duration-200"
|
||||
leave-from="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
|
||||
<DialogPanel
|
||||
class="w-full transform overflow-hidden rounded-lg bg-white p-6 text-left align-middle shadow-xl transition-all dark:bg-dark dark:text-white sm:max-w-lg">
|
||||
<slot />
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</Dialog>
|
||||
</TransitionRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Dialog, DialogOverlay, DialogPanel, TransitionRoot, TransitionChild } from '@headlessui/vue'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean }>()
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
|
||||
|
||||
function emitClose() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
27
fe/src/components/ui/Listbox.vue
Normal file
27
fe/src/components/ui/Listbox.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<Listbox v-slot="{ open }" :model-value="modelValue" @update:modelValue="updateValue">
|
||||
<div class="relative">
|
||||
<slot name="button" />
|
||||
<Transition enter="transition ease-out duration-100" enter-from="transform opacity-0 scale-95"
|
||||
enter-to="transform opacity-100 scale-100" leave="transition ease-in duration-75"
|
||||
leave-from="transform opacity-100 scale-100" leave-to="transform opacity-0 scale-95">
|
||||
<ListboxOptions v-if="open"
|
||||
class="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-none dark:bg-dark dark:text-white sm:text-sm">
|
||||
<slot />
|
||||
</ListboxOptions>
|
||||
</Transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Listbox, ListboxOptions } from '@headlessui/vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps<{ modelValue: unknown }>()
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', value: unknown): void }>()
|
||||
|
||||
function updateValue(value: unknown) {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
</script>
|
17
fe/src/components/ui/Menu.vue
Normal file
17
fe/src/components/ui/Menu.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<Menu as="div" class="relative inline-block text-left">
|
||||
<slot name="button" />
|
||||
<Transition enter="transition ease-out duration-100" enter-from="transform opacity-0 scale-95"
|
||||
enter-to="transform opacity-100 scale-100" leave="transition ease-in duration-75"
|
||||
leave-from="transform opacity-100 scale-100" leave-to="transform opacity-0 scale-95">
|
||||
<MenuItems
|
||||
class="absolute right-0 z-30 mt-2 w-56 origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black/5 focus:outline-none dark:bg-dark dark:text-white">
|
||||
<slot />
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/vue'
|
||||
</script>
|
27
fe/src/components/ui/Switch.vue
Normal file
27
fe/src/components/ui/Switch.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<Switch :model-value="modelValue" @update:modelValue="emit('update:modelValue', $event)" :class="[
|
||||
modelValue ? activeClasses : inactiveClasses,
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ui-not-focusable',
|
||||
]">
|
||||
<span aria-hidden="true" :class="[
|
||||
modelValue ? 'translate-x-6' : 'translate-x-1',
|
||||
'inline-block h-4 w-4 transform rounded-full bg-white transition',
|
||||
]" />
|
||||
</Switch>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Switch } from '@headlessui/vue'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; color?: 'primary' | 'success' | 'danger' }>()
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
|
||||
|
||||
const activeColorMap = {
|
||||
primary: 'bg-primary',
|
||||
success: 'bg-success',
|
||||
danger: 'bg-danger',
|
||||
}
|
||||
|
||||
const activeClasses = activeColorMap[props.color ?? 'primary']
|
||||
const inactiveClasses = 'bg-neutral/40'
|
||||
</script>
|
14
fe/src/components/ui/Tabs.vue
Normal file
14
fe/src/components/ui/Tabs.vue
Normal file
@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<TabGroup as="div">
|
||||
<div class="flex space-x-2" v-if="$slots.tabs">
|
||||
<slot name="tabs" />
|
||||
</div>
|
||||
<TabPanels class="mt-4">
|
||||
<slot />
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TabGroup, TabPanels } from '@headlessui/vue'
|
||||
</script>
|
11
fe/src/components/ui/TransitionExpand.vue
Normal file
11
fe/src/components/ui/TransitionExpand.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<Transition enter="transition-all ease-out duration-300" enter-from="max-h-0 opacity-0"
|
||||
enter-to="max-h-screen opacity-100" leave="transition-all ease-in duration-200"
|
||||
leave-from="max-h-screen opacity-100" leave-to="max-h-0 opacity-0">
|
||||
<slot />
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Transition } from '@headlessui/vue'
|
||||
</script>
|
35
fe/src/components/ui/__tests__/Button.spec.ts
Normal file
35
fe/src/components/ui/__tests__/Button.spec.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Button from '../Button.vue'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
describe('Button.vue', () => {
|
||||
it('renders default (solid primary md) styles', () => {
|
||||
const wrapper = mount(Button, {
|
||||
slots: { default: 'Click Me' },
|
||||
})
|
||||
expect(wrapper.text()).toBe('Click Me')
|
||||
const cls = wrapper.attributes('class')
|
||||
expect(cls).toContain('bg-primary')
|
||||
expect(cls).toContain('px-4') // md size padding
|
||||
})
|
||||
|
||||
it('renders outline success variant', () => {
|
||||
const wrapper = mount(Button, {
|
||||
props: { variant: 'outline', color: 'success' },
|
||||
slots: { default: 'Save' },
|
||||
})
|
||||
const cls = wrapper.attributes('class')
|
||||
expect(cls).toContain('border-success')
|
||||
expect(cls).toContain('text-success')
|
||||
})
|
||||
|
||||
it('renders ghost danger lg size', () => {
|
||||
const wrapper = mount(Button, {
|
||||
props: { variant: 'ghost', color: 'danger', size: 'lg' },
|
||||
slots: { default: 'Delete' },
|
||||
})
|
||||
const cls = wrapper.attributes('class')
|
||||
expect(cls).toContain('text-danger')
|
||||
expect(cls).toContain('px-6') // lg size padding
|
||||
})
|
||||
})
|
22
fe/src/components/ui/__tests__/Dialog.spec.ts
Normal file
22
fe/src/components/ui/__tests__/Dialog.spec.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Dialog from '../Dialog.vue'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
describe('Dialog.vue', () => {
|
||||
it('renders slot content when open', () => {
|
||||
const wrapper = mount(Dialog, {
|
||||
props: { modelValue: true },
|
||||
slots: { default: '<p data-test="content">Hello</p>' },
|
||||
})
|
||||
expect(wrapper.find('[data-test="content"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not render when closed', () => {
|
||||
const wrapper = mount(Dialog, {
|
||||
props: { modelValue: false },
|
||||
slots: { default: '<p>Hidden</p>' },
|
||||
})
|
||||
// Should not render content
|
||||
expect(wrapper.html()).toBe('')
|
||||
})
|
||||
})
|
23
fe/src/components/ui/__tests__/Listbox.spec.ts
Normal file
23
fe/src/components/ui/__tests__/Listbox.spec.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import Listbox from '../Listbox.vue'
|
||||
|
||||
const Option = {
|
||||
template: '<div role="option" :value="value">{{ value }}</div>',
|
||||
props: ['value'],
|
||||
}
|
||||
|
||||
describe('Listbox.vue', () => {
|
||||
it('emits update when option selected', async () => {
|
||||
const wrapper = mount(Listbox, {
|
||||
props: { modelValue: 'one' },
|
||||
slots: {
|
||||
button: '<button data-test="toggle">Toggle</button>',
|
||||
default: '<div role="option">one</div>',
|
||||
},
|
||||
})
|
||||
await wrapper.find('[data-test="toggle"]').trigger('click')
|
||||
// The selection event is internal; basic mount test
|
||||
expect(wrapper.find('[data-test="toggle"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
20
fe/src/components/ui/__tests__/Menu.spec.ts
Normal file
20
fe/src/components/ui/__tests__/Menu.spec.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import Menu from '../Menu.vue'
|
||||
|
||||
// Provide a minimal menu item component for slot
|
||||
const MenuItemStub = {
|
||||
template: '<div role="menuitem">Item</div>',
|
||||
}
|
||||
|
||||
describe('Menu.vue', () => {
|
||||
it('renders menu button slot', () => {
|
||||
const wrapper = mount(Menu, {
|
||||
slots: {
|
||||
button: '<button data-test="trigger">Open</button>',
|
||||
default: MenuItemStub,
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('[data-test="trigger"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
14
fe/src/components/ui/__tests__/Switch.spec.ts
Normal file
14
fe/src/components/ui/__tests__/Switch.spec.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import UISwitch from '../Switch.vue'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
describe('Switch.vue', () => {
|
||||
it('emits update:modelValue when toggled', async () => {
|
||||
const wrapper = mount(UISwitch, {
|
||||
props: { modelValue: false },
|
||||
})
|
||||
// the headlessui switch renders a button element
|
||||
await wrapper.find('button').trigger('click')
|
||||
expect(wrapper.emitted()['update:modelValue']).toBeTruthy()
|
||||
})
|
||||
})
|
16
fe/src/components/ui/__tests__/Tabs.spec.ts
Normal file
16
fe/src/components/ui/__tests__/Tabs.spec.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import Tabs from '../Tabs.vue'
|
||||
|
||||
describe('Tabs.vue', () => {
|
||||
it('renders tabs and panels', () => {
|
||||
const wrapper = mount(Tabs, {
|
||||
slots: {
|
||||
tabs: '<button>Tab 1</button>',
|
||||
default: '<div>Panel 1</div>',
|
||||
},
|
||||
})
|
||||
expect(wrapper.text()).toContain('Tab 1')
|
||||
expect(wrapper.text()).toContain('Panel 1')
|
||||
})
|
||||
})
|
14
fe/src/components/ui/__tests__/TransitionExpand.spec.ts
Normal file
14
fe/src/components/ui/__tests__/TransitionExpand.spec.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import TransitionExpand from '../TransitionExpand.vue'
|
||||
|
||||
describe('TransitionExpand.vue', () => {
|
||||
it('renders slot content', () => {
|
||||
const wrapper = mount(TransitionExpand, {
|
||||
slots: {
|
||||
default: '<p data-test="content">Hello</p>',
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('[data-test="content"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
7
fe/src/components/ui/index.ts
Normal file
7
fe/src/components/ui/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export { default as Button } from './Button.vue'
|
||||
export { default as Dialog } from './Dialog.vue'
|
||||
export { default as Menu } from './Menu.vue'
|
||||
export { default as Listbox } from './Listbox.vue'
|
||||
export { default as Tabs } from './Tabs.vue'
|
||||
export { default as Switch } from './Switch.vue'
|
||||
export { default as TransitionExpand } from './TransitionExpand.vue'
|
@ -10,7 +10,7 @@ import deMessages from './i18n/de.json'
|
||||
import frMessages from './i18n/fr.json'
|
||||
import esMessages from './i18n/es.json'
|
||||
import nlMessages from './i18n/nl.json'
|
||||
import './assets/main.scss'
|
||||
import './assets/base.css'
|
||||
import { api, globalAxios } from '@/services/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
|
@ -12,6 +12,7 @@ import { useTimeEntryStore, type TimeEntry } from '../stores/timeEntryStore';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import type { UserPublic } from '@/types/user';
|
||||
import BaseIcon from '@/components/BaseIcon.vue';
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@ -813,8 +814,13 @@ const getAssignmentIdForUser = (userId: number): number | null => {
|
||||
<div class="assignment-main">
|
||||
<span class="assigned-user">{{ assignment.assigned_user?.name || assignment.assigned_user?.email
|
||||
}}</span>
|
||||
<span class="assignment-status" :class="{ completed: assignment.is_complete }">
|
||||
{{ assignment.is_complete ? '✅ Completed' : '⏳ Pending' }}
|
||||
<span class="assignment-status">
|
||||
<template v-if="assignment.is_complete">
|
||||
<BaseIcon name="heroicons:check-circle-20-solid" class="w-4 h-4 text-success inline" /> Completed
|
||||
</template>
|
||||
<template v-else>
|
||||
<BaseIcon name="heroicons:clock-20-solid" class="w-4 h-4 text-warning inline" /> Pending
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="assignment-details">
|
||||
|
49
fe/tailwind.config.ts
Normal file
49
fe/tailwind.config.ts
Normal file
@ -0,0 +1,49 @@
|
||||
// @ts-nocheck
|
||||
import type { Config } from 'tailwindcss'
|
||||
import forms from '@tailwindcss/forms'
|
||||
import typography from '@tailwindcss/typography'
|
||||
import headlessui from '@headlessui/tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
'./index.html',
|
||||
'./src/**/*.{vue,js,ts,jsx,tsx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#FF7B54',
|
||||
secondary: '#FFB26B',
|
||||
accent: '#FFD56B',
|
||||
info: '#54C7FF',
|
||||
success: '#A0E7A0',
|
||||
warning: '#FFD56B',
|
||||
danger: '#FF4D4D',
|
||||
dark: '#393E46',
|
||||
light: '#FFF8F0',
|
||||
black: '#000000',
|
||||
neutral: '#64748B',
|
||||
},
|
||||
spacing: {
|
||||
1: '4px',
|
||||
4: '16px',
|
||||
6: '24px',
|
||||
8: '32px',
|
||||
12: '48px',
|
||||
16: '64px',
|
||||
},
|
||||
fontFamily: {
|
||||
hand: ['"Patrick Hand"', 'cursive'],
|
||||
sans: ['Inter', 'ui-sans-serif', 'system-ui'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
forms,
|
||||
typography,
|
||||
headlessui({ prefix: 'ui' }),
|
||||
],
|
||||
}
|
||||
|
||||
export default config
|
@ -1,11 +1,17 @@
|
||||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
"include": ["src/**/__tests__/*", "env.d.ts"],
|
||||
"include": [
|
||||
"src/**/__tests__/*",
|
||||
"env.d.ts"
|
||||
],
|
||||
"exclude": [],
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo",
|
||||
|
||||
"lib": [],
|
||||
"types": ["node", "jsdom"]
|
||||
"types": [
|
||||
"node",
|
||||
"jsdom",
|
||||
"vitest"
|
||||
]
|
||||
}
|
||||
}
|
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "doe",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
Loading…
Reference in New Issue
Block a user