fix: visuals reference matching overhaul and better workflow
This commit is contained in:
1 parent
930b836241
commit
d493a181e1
25 files changed
+461
-241
No files matched your search
@@ -33,29 +33,38 @@ onUnmounted(() => {
|
||||
})
|
||||
|
||||
const controlSize = computed(() => (belowSm.value ? 'xl' : undefined))
|
||||
const controlClass = computed(() => (belowSm.value ? '!h-11 !text-base' : '!h-10 !text-sm'))
|
||||
const controlClass = computed(() => (belowSm.value ? '!h-11 !text-base' : '!h-10 !text-base'))
|
||||
const inputClass = computed(() => (belowSm.value ? '!h-11 !text-lg' : '!h-10 !text-base'))
|
||||
|
||||
const mainClass = computed(() => [
|
||||
'flex min-h-screen flex-col items-center',
|
||||
belowSm.value ? 'bg-default' : 'bg-muted pt-[12vh]',
|
||||
'flex min-h-screen flex-col items-center gap-5',
|
||||
belowSm.value ? 'bg-default pt-10' : 'bg-muted pt-[12vh]',
|
||||
])
|
||||
|
||||
const cardClass = computed(() => (belowSm.value ? 'w-full' : 'w-full max-w-[400px]'))
|
||||
|
||||
const cardUi = computed(() => ({
|
||||
root: belowSm.value ? 'ring-0' : '',
|
||||
container: belowSm.value ? 'pt-2 px-5 pb-0' : 'p-10',
|
||||
header: 'mb-6',
|
||||
title: 'text-xl font-semibold leading-7',
|
||||
root: belowSm.value ? 'rounded-none ring-0' : 'rounded-none ring-0 border border-default',
|
||||
container: belowSm.value
|
||||
? 'flex flex-col gap-6 px-5 pt-2 pb-0 lg:flex'
|
||||
: 'flex flex-col gap-6 p-10 sm:p-10 lg:flex',
|
||||
header: props.title ? 'mb-6' : 'mb-0',
|
||||
title: 'text-xl/7 font-semibold',
|
||||
description: 'mt-1.5 text-base text-muted',
|
||||
}))
|
||||
|
||||
const footerClass = computed(() => [
|
||||
'mt-4 flex w-full items-center justify-between',
|
||||
belowSm.value ? 'pt-0 px-5 pb-6' : 'p-0',
|
||||
'flex w-full flex-wrap items-center justify-between gap-3 text-sm text-muted',
|
||||
belowSm.value ? 'px-5 pb-6' : 'p-0',
|
||||
cardClass.value,
|
||||
])
|
||||
|
||||
const colorModeSelectUi = { trailing: 'hidden' }
|
||||
const colorModeSelectUi = {
|
||||
base: 'h-7 ps-7 pe-2 text-sm text-toned',
|
||||
leading: 'ps-2',
|
||||
leadingIcon: 'size-3.5 text-toned',
|
||||
trailing: 'hidden',
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.title,
|
||||
@@ -96,16 +105,20 @@ watch(
|
||||
<h1 ref="titleEl" tabindex="-1" class="outline-none">{{ title }}</h1>
|
||||
</template>
|
||||
|
||||
<div v-if="slots.notice" class="mb-4">
|
||||
<slot name="notice" />
|
||||
</div>
|
||||
<slot v-if="slots.notice" name="notice" />
|
||||
|
||||
<slot :control-size="controlSize" :control-class="controlClass" />
|
||||
<slot
|
||||
:control-size="controlSize"
|
||||
:control-class="controlClass"
|
||||
:input-class="inputClass"
|
||||
/>
|
||||
</UPageCard>
|
||||
|
||||
<footer :class="footerClass">
|
||||
<slot name="footer" />
|
||||
<UColorModeSelect size="sm" class="ms-auto" :ui="colorModeSelectUi" />
|
||||
<footer v-if="!belowSm || $slots.footer" :class="footerClass">
|
||||
<span class="flex flex-wrap items-center gap-1.5">
|
||||
<slot name="footer" />
|
||||
</span>
|
||||
<UColorModeSelect size="sm" :ui="colorModeSelectUi" />
|
||||
</footer>
|
||||
</main>
|
||||
</template>
|
||||
@@ -76,7 +76,12 @@ function onCancel() {
|
||||
:content="{ role: 'alertdialog', 'aria-busy': pending ? 'true' : undefined }"
|
||||
:ui="{
|
||||
content:
|
||||
'max-sm:!inset-x-0 max-sm:!bottom-0 max-sm:!top-auto max-sm:!left-0 max-sm:!right-0 max-sm:!translate-x-0 max-sm:!translate-y-0 max-sm:!w-full max-sm:!max-w-none',
|
||||
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-[220px] sm:max-w-110 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
|
||||
header: 'min-h-0 px-6 pt-6 pb-0 sm:px-6 max-sm:px-5 max-sm:pt-5',
|
||||
title: 'text-lg max-sm:text-[1.0625rem]/6',
|
||||
description: 'mt-2 text-base text-toned max-sm:text-[0.9375rem]/[23px]',
|
||||
body: 'px-6 pt-4 pb-0 sm:px-6 sm:pt-4 sm:pb-0 max-sm:px-5',
|
||||
footer: 'p-6 sm:px-6 max-sm:p-5',
|
||||
}"
|
||||
>
|
||||
<template v-if="error" #body>
|
||||
@@ -96,7 +101,7 @@ function onCancel() {
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
block
|
||||
class="sm:w-auto"
|
||||
class="h-11 justify-center text-[0.9375rem] text-highlighted disabled:text-dimmed disabled:opacity-100 sm:h-8 sm:w-auto sm:px-3 sm:text-sm"
|
||||
:disabled="pending"
|
||||
@click="onCancel"
|
||||
>
|
||||
@@ -107,7 +112,7 @@ function onCancel() {
|
||||
:loading="pending"
|
||||
:disabled="pending"
|
||||
block
|
||||
class="sm:w-auto"
|
||||
class="h-11 justify-center text-[0.9375rem] sm:h-8 sm:w-auto sm:px-3 sm:text-sm"
|
||||
@click="onConfirm"
|
||||
>
|
||||
{{ confirmText }}
|
||||
|
||||
@@ -11,16 +11,23 @@ const props = defineProps<{
|
||||
showErrors: boolean
|
||||
}>()
|
||||
|
||||
function itemClass(rule: PasswordRule) {
|
||||
function iconClass(rule: PasswordRule) {
|
||||
if (rule.met) {
|
||||
return 'text-success'
|
||||
}
|
||||
return props.showErrors ? 'text-error' : 'text-toned'
|
||||
return props.showErrors ? 'text-error' : 'text-muted'
|
||||
}
|
||||
|
||||
function textClass(rule: PasswordRule) {
|
||||
if (rule.met) {
|
||||
return 'text-default'
|
||||
}
|
||||
return props.showErrors ? 'text-error' : 'text-muted'
|
||||
}
|
||||
|
||||
function itemIcon(rule: PasswordRule) {
|
||||
if (rule.met) {
|
||||
return 'i-lucide-circle-check'
|
||||
return 'i-lucide-check'
|
||||
}
|
||||
return props.showErrors ? 'i-lucide-circle-alert' : 'i-lucide-circle'
|
||||
}
|
||||
@@ -28,21 +35,18 @@ function itemIcon(rule: PasswordRule) {
|
||||
const items = computed(() =>
|
||||
props.rules.map((rule) => ({
|
||||
...rule,
|
||||
classes: itemClass(rule),
|
||||
iconClass: iconClass(rule),
|
||||
textClass: textClass(rule),
|
||||
icon: itemIcon(rule),
|
||||
})),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="flex flex-col gap-1">
|
||||
<li
|
||||
v-for="item in items"
|
||||
:key="item.label"
|
||||
:class="['flex items-center gap-1.5 text-sm', item.classes]"
|
||||
>
|
||||
<UIcon :name="item.icon" class="shrink-0" />
|
||||
{{ item.label }}
|
||||
<ul class="flex flex-col gap-1.5">
|
||||
<li v-for="item in items" :key="item.label" class="flex items-center gap-2 text-xs/[18px]">
|
||||
<UIcon :name="item.icon" :class="['size-3.5 shrink-0', item.iconClass]" />
|
||||
<span :class="item.textClass">{{ item.label }}</span>
|
||||
<span class="sr-only">{{ item.met ? 'met' : 'not met' }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -21,9 +21,9 @@ const props = defineProps<{
|
||||
const role = computed(() => (props.tone === 'error' ? 'alert' : 'status'))
|
||||
|
||||
const toneTileClass: Record<'success' | 'error' | 'info', string> = {
|
||||
success: 'bg-success/10 text-success',
|
||||
error: 'bg-error/10 text-error',
|
||||
info: 'bg-info/10 text-info',
|
||||
success: 'bg-(--rc-success-soft) text-success',
|
||||
error: 'bg-(--rc-error-soft) text-error',
|
||||
info: 'bg-(--rc-info-soft) text-info',
|
||||
}
|
||||
|
||||
const tileClass = computed(() => toneTileClass[props.tone])
|
||||
@@ -38,24 +38,31 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<div :role="role" class="flex flex-col gap-4">
|
||||
<div :class="['flex h-11 w-11 items-center justify-center', tileClass]">
|
||||
<UIcon :name="icon" class="h-6 w-6" />
|
||||
<div :class="['flex size-11 items-center justify-center', tileClass]">
|
||||
<UIcon :name="icon" class="size-5.5" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<h1 ref="titleEl" tabindex="-1" class="text-xl font-semibold leading-7 outline-none">
|
||||
<h1
|
||||
ref="titleEl"
|
||||
tabindex="-1"
|
||||
class="text-xl/7 font-semibold text-highlighted outline-none"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p class="text-toned text-sm">{{ description }}</p>
|
||||
<p class="text-base text-pretty text-toned">{{ description }}</p>
|
||||
<slot />
|
||||
</div>
|
||||
<slot />
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div v-if="actions.length" class="flex w-full flex-col gap-2">
|
||||
<UButton
|
||||
v-for="action in actions"
|
||||
:key="action.label"
|
||||
block
|
||||
size="lg"
|
||||
:color="action.color ?? 'primary'"
|
||||
:variant="action.variant ?? 'solid'"
|
||||
:class="[
|
||||
'h-11 justify-center text-base sm:h-10',
|
||||
action.variant === 'outline' ? 'text-highlighted' : '',
|
||||
]"
|
||||
:to="action.to"
|
||||
:disabled="action.disabled"
|
||||
@click="action.onClick"
|
||||
|
||||
@@ -116,7 +116,7 @@ describe("UPageCard's title is the page h1, and its description uses UPageCard's
|
||||
})
|
||||
|
||||
describe('The page sits on the muted background from sm up, and the card keeps its border', () => {
|
||||
it('uses the muted background token, keeps the card ring, capped width and 12vh top offset', () => {
|
||||
it('uses the muted background token, keeps the card border, capped width and 12vh top offset', () => {
|
||||
stubMatchMedia(false)
|
||||
const wrapper = mount(AuthShell, {
|
||||
props: { title: 'Log in', description: 'Welcome back.' },
|
||||
@@ -130,7 +130,7 @@ describe('The page sits on the muted background from sm up, and the card keeps i
|
||||
|
||||
const card = wrapper.find('[data-testid="auth-card"]')
|
||||
expect(card.classes()).toContain('max-w-[400px]')
|
||||
expect(card.classes().some((c) => c === 'ring-0')).toBe(false)
|
||||
expect(card.classes()).toContain('border')
|
||||
|
||||
const container = wrapper.find('[data-slot="container"]')
|
||||
expect(container.classes()).not.toContain('p-5')
|
||||
|
||||
@@ -55,57 +55,6 @@ afterEach(async () => {
|
||||
resetAuthShellNavigation()
|
||||
})
|
||||
|
||||
describe('Card padding and control heights at sm and up', () => {
|
||||
it('pads the card 40px on every side and sizes controls at 40px, 14px text', async () => {
|
||||
stubMatchMedia(false)
|
||||
const wrapper = await mountLogin()
|
||||
|
||||
const container = wrapper.find('[data-slot="container"]')
|
||||
expect(container.classes()).toContain('p-10')
|
||||
expect(container.classes()).not.toContain('pt-2')
|
||||
|
||||
const usernameInput = wrapper.find('input[placeholder="Your username"]')
|
||||
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
|
||||
const submitButton = wrapper.find('button[type="submit"]')
|
||||
|
||||
for (const el of [usernameInput, passwordInput, submitButton]) {
|
||||
expect(el.classes()).toContain('!h-10')
|
||||
expect(el.classes()).toContain('!text-sm')
|
||||
}
|
||||
|
||||
const title = wrapper.find('[data-slot="title"]')
|
||||
expect(title.classes()).toContain('text-xl')
|
||||
expect(title.classes()).toContain('font-semibold')
|
||||
expect(title.classes()).toContain('leading-7')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Card padding and control heights below sm', () => {
|
||||
it('splits card/footer padding and sizes controls at 44px, 16px text', async () => {
|
||||
stubMatchMedia(true)
|
||||
const wrapper = await mountLogin()
|
||||
|
||||
const container = wrapper.find('[data-slot="container"]')
|
||||
expect(container.classes()).toContain('pt-2')
|
||||
expect(container.classes()).toContain('px-5')
|
||||
expect(container.classes()).toContain('pb-0')
|
||||
|
||||
const footer = wrapper.find('footer')
|
||||
expect(footer.classes()).toContain('pt-0')
|
||||
expect(footer.classes()).toContain('px-5')
|
||||
expect(footer.classes()).toContain('pb-6')
|
||||
|
||||
const usernameInput = wrapper.find('input[placeholder="Your username"]')
|
||||
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
|
||||
const submitButton = wrapper.find('button[type="submit"]')
|
||||
|
||||
for (const el of [usernameInput, passwordInput, submitButton]) {
|
||||
expect(el.classes()).toContain('!h-11')
|
||||
expect(el.classes()).toContain('!text-base')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('"Forgot password?" is underlined and muted', () => {
|
||||
it('uses the muted toned color, underline and 3px offset, no other emphasis', async () => {
|
||||
const wrapper = await mountLogin()
|
||||
@@ -151,6 +100,7 @@ describe('A field error shows the circle-alert icon with no extra focus halo', (
|
||||
|
||||
describe('The theme select is right-aligned in the footer with no chevron', () => {
|
||||
it('renders as the last child, showing only the mode icon and label', async () => {
|
||||
stubMatchMedia(false)
|
||||
const wrapper = await mountLogin()
|
||||
|
||||
const footer = wrapper.find('footer')
|
||||
@@ -201,7 +151,9 @@ describe('The alert renders inside the card, under the description', () => {
|
||||
expect(html.indexOf(text)).toBeLessThan(html.indexOf('Username'))
|
||||
expect(card.element.contains(usernameInput.element)).toBe(true)
|
||||
|
||||
expect(alert.classes().some((c) => /^bg-(info|success)\/10$/.test(c))).toBe(true)
|
||||
expect(alert.classes().some((c) => /^bg-\(--rc-(info|success)-soft\)$/.test(c))).toBe(
|
||||
true,
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user