fix: small design harmonization and overhaul
This commit is contained in:
1 parent
d6566743a7
commit
a2d224240c
9 files changed
+612
-29
No files matched your search
@@ -161,6 +161,9 @@ const sidebarCollapsed = useSidebarCollapsed()
|
||||
|
||||
const sidebarUi = computed(() => ({
|
||||
root: sidebarCollapsed.value ? 'min-w-14 px-1 py-3' : 'min-w-14 p-3',
|
||||
content: 'w-75 gap-0.5 divide-y-0 p-3 shadow-modal',
|
||||
overlay: 'bg-(--rc-overlay)',
|
||||
toggle: 'hidden',
|
||||
header: 'mb-3 h-10 gap-2.5 px-1.5',
|
||||
body: 'gap-0.5 p-0',
|
||||
footer: 'p-0',
|
||||
@@ -170,12 +173,13 @@ const navUi = computed(() => ({
|
||||
list: 'flex flex-col gap-0.5',
|
||||
link: sidebarCollapsed.value
|
||||
? 'h-8 justify-center gap-0 px-0 text-sm'
|
||||
: 'h-8 gap-2.5 px-2.5 py-0 text-sm [li[data-state=open]>&]:font-medium [li[data-state=open]>&]:text-highlighted',
|
||||
: 'h-8 gap-2.5 px-2.5 py-0 text-sm max-lg:h-11 [li[data-state=open]>&:not([aria-current=page])]:font-medium [li[data-state=open]>&:not([aria-current=page])]:text-highlighted',
|
||||
linkLeadingIcon: 'size-4',
|
||||
linkTrailingIcon: 'size-3.5 group-data-[state=open]:rotate-90',
|
||||
label: 'px-2.5 pt-1.5 pb-0.5 pl-3 text-[11px] leading-4 font-semibold tracking-[.04em] text-muted uppercase',
|
||||
childList: 'ms-[19px] gap-0.5 border-s border-default ps-0',
|
||||
childLink: 'h-8 gap-2.5 py-0 pr-2.5 pl-3 text-sm',
|
||||
childList: 'ms-[19px] mt-0.5 flex flex-col gap-0.5 border-s border-default ps-0',
|
||||
childItem: 'ms-0 ps-0',
|
||||
childLink: 'h-8 max-lg:h-11 gap-2.5 py-0 pr-2.5 pl-3 text-sm',
|
||||
childLinkIcon: 'size-3.5',
|
||||
}))
|
||||
const loggingOut = ref(false)
|
||||
|
||||
@@ -147,7 +147,7 @@ const items = computed(() => [
|
||||
:aria-expanded="chipExpanded"
|
||||
:class="chipClass"
|
||||
:trailing-icon="collapsed ? undefined : 'i-lucide-chevrons-up-down'"
|
||||
:ui="{ trailingIcon: 'ms-auto size-3.5 text-current' }"
|
||||
:ui="{ trailingIcon: 'ms-auto size-3.5 text-current max-lg:hidden' }"
|
||||
:aria-label="triggerAriaLabel"
|
||||
>
|
||||
<template #leading>
|
||||
|
||||
@@ -6,6 +6,8 @@ import router from '@/router'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useColorMode } from '@/composables/useColorMode'
|
||||
import DashboardShell from '../DashboardShell.vue'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from './viewport'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
@@ -429,3 +431,342 @@ describe('DashboardShell Settings expands to the Media subpages', () => {
|
||||
expect(nav.text()).not.toContain('Cache')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DashboardShell selected nav rows use sand with on-sand text', () => {
|
||||
const SAND = 'bg-(--rc-sand)'
|
||||
const ON_SAND = 'text-(--rc-on-sand)'
|
||||
const STATES = ['', 'hover:', 'active:', 'focus-visible:']
|
||||
const selectedClasses = STATES.flatMap((state) => [`${state}${SAND}`, `${state}${ON_SAND}`])
|
||||
const SCOPED_BOLD = '[li[data-state=open]>&:not([aria-current=page])]:font-medium'
|
||||
const UNSCOPED_BOLD = '[li[data-state=open]>&]:font-medium'
|
||||
|
||||
it.each([
|
||||
'/settings',
|
||||
'/settings/media/variants',
|
||||
'/settings/media/cache',
|
||||
'/home',
|
||||
'/users',
|
||||
'/activity',
|
||||
])('fills the selected row on %s with sand and on-sand in every state', async (path) => {
|
||||
const wrapper = await mountShell(path, { role: 'owner' })
|
||||
|
||||
const nav = wrapper.find('nav[aria-label="Main"]')
|
||||
const selected = nav.find(`a[href="${path}"]`)
|
||||
expect(selected.attributes('aria-current')).toBe('page')
|
||||
expect(selected.classes()).toEqual(expect.arrayContaining(selectedClasses))
|
||||
expect(selected.classes()).toContain('focus-visible:outline-(--ui-primary)')
|
||||
expect(selected.find('[class*="--rc-on-sand"]').exists()).toBe(true)
|
||||
|
||||
const unselected = nav
|
||||
.findAll('a')
|
||||
.filter((link) => link.attributes('aria-current') === undefined)
|
||||
expect(unselected.length).toBeGreaterThan(0)
|
||||
for (const link of unselected) {
|
||||
for (const cls of selectedClasses) {
|
||||
expect(link.classes()).not.toContain(cls)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the unfilled Settings parent bold when a child is selected', async () => {
|
||||
const wrapper = await mountShell('/settings/media/variants', { role: 'owner' })
|
||||
|
||||
const parent = wrapper.find('nav[aria-label="Main"] a[href="/settings"]')
|
||||
expect(parent.attributes('aria-current')).toBeUndefined()
|
||||
expect(parent.classes()).not.toContain(SAND)
|
||||
expect(parent.classes()).toContain(SCOPED_BOLD)
|
||||
expect(parent.classes()).not.toContain(UNSCOPED_BOLD)
|
||||
})
|
||||
|
||||
it('scopes the open-parent bold rule away from the selected row', async () => {
|
||||
const wrapper = await mountShell('/settings', { role: 'owner' })
|
||||
|
||||
const selected = wrapper.find('nav[aria-label="Main"] a[href="/settings"]')
|
||||
expect(selected.classes()).toContain(SCOPED_BOLD)
|
||||
expect(selected.classes()).not.toContain(UNSCOPED_BOLD)
|
||||
expect(selected.classes()).not.toContain('[li[data-state=open]>&]:text-highlighted')
|
||||
})
|
||||
|
||||
it('draws drawer rows 44 px tall with the same selected treatment', async () => {
|
||||
await mountShell('/settings', { role: 'owner', width: MOBILE_WIDTH })
|
||||
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
|
||||
toggle.click()
|
||||
await flushPromises()
|
||||
|
||||
const settings = document.body.querySelector(
|
||||
'[role="dialog"] nav[aria-label="Main"] a[href="/settings"]',
|
||||
) as HTMLElement
|
||||
expect(settings.classList.contains('max-lg:h-11')).toBe(true)
|
||||
for (const cls of selectedClasses) {
|
||||
expect(settings.classList.contains(cls)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const mainCss = readFileSync(join(import.meta.dirname, '../../assets/main.css'), 'utf8')
|
||||
|
||||
function declarationsOf(selector: string) {
|
||||
const start = mainCss.indexOf(`\n${selector} {`)
|
||||
const end = mainCss.indexOf('\n}', start)
|
||||
const block = mainCss.slice(start, end)
|
||||
return new Map(
|
||||
[...block.matchAll(/^\t(--[a-z0-9-]+): ([^;]+);$/gm)].map((match) => [
|
||||
match[1]!,
|
||||
match[2]!,
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
function resolveToken(declarations: Map<string, string>, token: string) {
|
||||
const value = declarations.get(token) ?? declarationsOf(':root').get(token)!
|
||||
const alias = /^var\((--[a-z0-9-]+)\)$/.exec(value)
|
||||
if (!alias) {
|
||||
return value
|
||||
}
|
||||
return declarations.get(alias[1]!) ?? resolveFromTheme(alias[1]!)
|
||||
}
|
||||
|
||||
function resolveFromTheme(token: string) {
|
||||
return new RegExp(`^\t${token}: ([^;]+);$`, 'm').exec(mainCss)![1]!
|
||||
}
|
||||
|
||||
const tokenRows: [string, 'light' | 'dark', string][] = [
|
||||
['--rc-sand', 'light', '#e8dfd1'],
|
||||
['--rc-sand', 'dark', '#d4c5b0'],
|
||||
['--rc-on-sand', 'light', '#111111'],
|
||||
['--rc-on-sand', 'dark', '#111111'],
|
||||
['--rc-success-soft', 'light', '#eaefea'],
|
||||
['--rc-success-soft', 'dark', '#1f241f'],
|
||||
['--rc-info-soft', 'light', '#ebeef0'],
|
||||
['--rc-info-soft', 'dark', '#202428'],
|
||||
['--rc-warning-soft', 'light', '#f0ede6'],
|
||||
['--rc-warning-soft', 'dark', '#2a251e'],
|
||||
['--rc-error-soft', 'light', '#f3eae9'],
|
||||
['--rc-error-soft', 'dark', '#2c201f'],
|
||||
['--rc-overlay', 'light', 'rgb(10 10 10 / 0.4)'],
|
||||
['--rc-overlay', 'dark', 'rgb(0 0 0 / 0.6)'],
|
||||
['--rc-shadow-pop', 'light', '0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06)'],
|
||||
['--rc-shadow-pop', 'dark', '0 4px 16px rgb(0 0 0 / 0.5)'],
|
||||
['--rc-shadow-modal', 'light', '0 16px 48px rgb(17 17 17 / 0.18)'],
|
||||
['--rc-shadow-modal', 'dark', '0 16px 48px rgb(0 0 0 / 0.6)'],
|
||||
]
|
||||
|
||||
const lightUiTokens = {
|
||||
'--ui-bg': 'var(--color-white)',
|
||||
'--ui-bg-muted': 'var(--color-chalk-50)',
|
||||
'--ui-bg-elevated': 'var(--color-chalk-100)',
|
||||
'--ui-bg-accented': 'var(--color-chalk-200)',
|
||||
'--ui-bg-inverted': 'var(--color-chalk-900)',
|
||||
'--ui-text-dimmed': 'var(--color-chalk-400)',
|
||||
'--ui-text-muted': 'var(--color-chalk-500)',
|
||||
'--ui-text-toned': 'var(--color-chalk-600)',
|
||||
'--ui-text': 'var(--color-chalk-800)',
|
||||
'--ui-text-highlighted': 'var(--color-chalk-900)',
|
||||
'--ui-text-inverted': 'var(--color-white)',
|
||||
'--ui-border': 'var(--color-chalk-200)',
|
||||
'--ui-border-muted': 'var(--color-chalk-100)',
|
||||
'--ui-border-accented': 'var(--color-chalk-450)',
|
||||
'--ui-border-inverted': 'var(--color-chalk-900)',
|
||||
'--ui-primary': 'var(--ui-color-primary-900)',
|
||||
'--ui-secondary': 'var(--ui-color-secondary-700)',
|
||||
'--ui-success': 'var(--ui-color-success-700)',
|
||||
'--ui-info': 'var(--ui-color-info-700)',
|
||||
'--ui-warning': 'var(--ui-color-warning-700)',
|
||||
'--ui-error': 'var(--ui-color-error-700)',
|
||||
}
|
||||
|
||||
const darkUiTokens = {
|
||||
'--ui-bg': 'var(--color-chalk-900)',
|
||||
'--ui-bg-muted': 'var(--color-chalk-950)',
|
||||
'--ui-bg-elevated': 'var(--color-chalk-850)',
|
||||
'--ui-bg-accented': 'var(--color-chalk-800)',
|
||||
'--ui-bg-inverted': 'var(--color-chalk-50)',
|
||||
'--ui-text-dimmed': 'var(--color-chalk-500)',
|
||||
'--ui-text-muted': 'var(--color-chalk-400)',
|
||||
'--ui-text-toned': 'var(--color-chalk-300)',
|
||||
'--ui-text': 'var(--color-chalk-200)',
|
||||
'--ui-text-highlighted': 'var(--color-chalk-50)',
|
||||
'--ui-text-inverted': 'var(--color-chalk-900)',
|
||||
'--ui-border': 'var(--color-chalk-700)',
|
||||
'--ui-border-muted': 'var(--color-chalk-800)',
|
||||
'--ui-border-accented': 'var(--color-chalk-500)',
|
||||
'--ui-border-inverted': 'var(--color-chalk-50)',
|
||||
'--ui-primary': 'var(--ui-color-primary-50)',
|
||||
'--ui-secondary': 'var(--ui-color-secondary-300)',
|
||||
'--ui-success': 'var(--ui-color-success-300)',
|
||||
'--ui-info': 'var(--ui-color-info-300)',
|
||||
'--ui-warning': 'var(--ui-color-warning-300)',
|
||||
'--ui-error': 'var(--ui-color-error-300)',
|
||||
}
|
||||
|
||||
function uiTokensOf(declarations: Map<string, string>) {
|
||||
return Object.fromEntries(
|
||||
[...declarations].filter(
|
||||
([name]) => name.startsWith('--ui-') && !/radius|header|container/.test(name),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
describe('main.css declares the shell design tokens', () => {
|
||||
it.each(tokenRows)(
|
||||
'%s resolves to the foundations value in the %s theme',
|
||||
(token, theme, value) => {
|
||||
const declarations = declarationsOf(theme === 'light' ? ':root' : '.dark')
|
||||
expect(resolveToken(declarations, token)).toBe(value)
|
||||
},
|
||||
)
|
||||
|
||||
it('declares no --rc-canvas', () => {
|
||||
expect(mainCss).not.toContain('--rc-canvas')
|
||||
})
|
||||
|
||||
it('leaves every --ui-* token value unchanged', () => {
|
||||
expect(uiTokensOf(declarationsOf(':root'))).toEqual(lightUiTokens)
|
||||
expect(uiTokensOf(declarationsOf('.dark'))).toEqual(darkUiTokens)
|
||||
})
|
||||
})
|
||||
|
||||
const SELECTED_BASE = ['bg-(--rc-sand)', 'text-(--rc-on-sand)']
|
||||
const SELECTED_STATES = ['hover', 'active', 'focus-visible'].flatMap((state) => [
|
||||
`${state}:bg-(--rc-sand)`,
|
||||
`${state}:text-(--rc-on-sand)`,
|
||||
])
|
||||
const FORBIDDEN_TEXT = /(^|:)(text-muted|text-white|text-inverted)$/
|
||||
|
||||
function navLink(root: ParentNode, href: string) {
|
||||
return root.querySelector(`nav[aria-label="Main"] a[href="${href}"]`) as HTMLElement
|
||||
}
|
||||
|
||||
function classesOf(element: Element) {
|
||||
return Array.from(element.classList)
|
||||
}
|
||||
|
||||
function expectSelected(link: HTMLElement) {
|
||||
const classes = classesOf(link)
|
||||
for (const cls of [...SELECTED_BASE, ...SELECTED_STATES]) {
|
||||
expect(classes).toContain(cls)
|
||||
}
|
||||
expect(link.getAttribute('aria-current')).toBe('page')
|
||||
const forbidden = classes.filter((cls) => FORBIDDEN_TEXT.test(cls))
|
||||
expect(forbidden).toEqual([])
|
||||
const iconClasses = classesOf(link.querySelector('svg, [data-slot="linkLeadingIcon"]')!)
|
||||
expect(iconClasses.filter((cls) => FORBIDDEN_TEXT.test(cls))).toEqual([])
|
||||
}
|
||||
|
||||
function expectUnfilled(link: HTMLElement) {
|
||||
expect(link.getAttribute('aria-current')).toBeNull()
|
||||
expect(classesOf(link).filter((cls) => cls.includes('--rc-sand'))).toEqual([])
|
||||
}
|
||||
|
||||
function expectBoldParent(link: HTMLElement) {
|
||||
expectUnfilled(link)
|
||||
const classes = classesOf(link)
|
||||
expect(classes).toContain('[li[data-state=open]>&:not([aria-current=page])]:text-highlighted')
|
||||
expect(classes).toContain('[li[data-state=open]>&:not([aria-current=page])]:font-medium')
|
||||
expect(link.closest('li')!.getAttribute('data-state')).toBe('open')
|
||||
const chevron = link.querySelector('[data-slot="linkTrailingIcon"]')!
|
||||
expect(classesOf(chevron)).toContain('group-data-[state=open]:rotate-90')
|
||||
}
|
||||
|
||||
describe('DashboardShell selected nav rows keep the sand contract in every state', () => {
|
||||
it('gives the selected Settings row a 2 px --ui-primary focus outline and keeps the sand fill', async () => {
|
||||
const wrapper = await mountShell('/settings', { role: 'owner' })
|
||||
|
||||
const classes = classesOf(navLink(wrapper.element, '/settings'))
|
||||
expect(classes).toContain('focus-visible:outline-2')
|
||||
expect(classes).toContain('focus-visible:outline-(--ui-primary)')
|
||||
expect(classes).toContain('focus-visible:bg-(--rc-sand)')
|
||||
expect(classes).toContain('bg-(--rc-sand)')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['/settings', '/settings'],
|
||||
['/settings/media/variants', '/settings/media/variants'],
|
||||
['/settings/media/cache', '/settings/media/cache'],
|
||||
['/home', '/home'],
|
||||
['/users', '/users'],
|
||||
['/activity', '/activity'],
|
||||
])(
|
||||
'paints the selected row on %s with sand and never white or muted text',
|
||||
async (path, href) => {
|
||||
const wrapper = await mountShell(path, { role: 'owner' })
|
||||
|
||||
expectSelected(navLink(wrapper.element, href))
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps Variants and Cache unselected under the filled Settings row on /settings', async () => {
|
||||
const wrapper = await mountShell('/settings', { role: 'owner' })
|
||||
|
||||
expectUnfilled(navLink(wrapper.element, '/settings/media/variants'))
|
||||
expectUnfilled(navLink(wrapper.element, '/settings/media/cache'))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['/settings/media/variants', '/settings/media/cache'],
|
||||
['/settings/media/cache', '/settings/media/variants'],
|
||||
])('shows Settings unfilled, bold and open when %s is selected', async (path, other) => {
|
||||
const wrapper = await mountShell(path, { role: 'owner' })
|
||||
|
||||
expectBoldParent(navLink(wrapper.element, '/settings'))
|
||||
expectUnfilled(navLink(wrapper.element, other))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['/settings', 'N1'],
|
||||
['/settings/media/variants', 'N2'],
|
||||
])('applies the same contract in the dark theme on %s (%s)', async (path) => {
|
||||
document.documentElement.classList.add('dark')
|
||||
try {
|
||||
const wrapper = await mountShell(path, { role: 'owner' })
|
||||
|
||||
if (path === '/settings') {
|
||||
expectSelected(navLink(wrapper.element, '/settings'))
|
||||
expectUnfilled(navLink(wrapper.element, '/settings/media/variants'))
|
||||
expectUnfilled(navLink(wrapper.element, '/settings/media/cache'))
|
||||
} else {
|
||||
expectSelected(navLink(wrapper.element, path))
|
||||
expectBoldParent(navLink(wrapper.element, '/settings'))
|
||||
}
|
||||
} finally {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('DashboardShell mobile drawer rows', () => {
|
||||
async function openDrawer(path: string) {
|
||||
await mountShell(path, { role: 'owner', width: MOBILE_WIDTH })
|
||||
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
|
||||
toggle.click()
|
||||
await flushPromises()
|
||||
return document.body.querySelector('[role="dialog"]') as HTMLElement
|
||||
}
|
||||
|
||||
it('draws every drawer row 44 px tall', async () => {
|
||||
const drawer = await openDrawer('/settings')
|
||||
|
||||
const links = Array.from(drawer.querySelectorAll('nav[aria-label="Main"] a'))
|
||||
expect(links).toHaveLength(9)
|
||||
for (const link of links) {
|
||||
expect(classesOf(link)).toContain('max-lg:h-11')
|
||||
}
|
||||
})
|
||||
|
||||
it('lists Variants and Cache unselected under the filled Settings row on /settings', async () => {
|
||||
const drawer = await openDrawer('/settings')
|
||||
|
||||
expect(drawer.querySelector('nav[aria-label="Main"]')!.textContent).toContain('Media')
|
||||
expectSelected(navLink(drawer, '/settings'))
|
||||
expectUnfilled(navLink(drawer, '/settings/media/variants'))
|
||||
expectUnfilled(navLink(drawer, '/settings/media/cache'))
|
||||
})
|
||||
|
||||
it('fills Variants and leaves Settings unfilled and bold on /settings/media/variants', async () => {
|
||||
const drawer = await openDrawer('/settings/media/variants')
|
||||
|
||||
expectSelected(navLink(drawer, '/settings/media/variants'))
|
||||
expectBoldParent(navLink(drawer, '/settings'))
|
||||
expectUnfilled(navLink(drawer, '/settings/media/cache'))
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user