fix(a11y): readable link text, 24px clear target, h2 widget titles, skip link focus #175
No files matched your search
@@ -34,6 +34,7 @@ import { useAppTreeRoutes } from '@/lib/hooks/use-routes'
|
||||
import { useDocumentTitle } from '@/lib/hooks/use-document-title'
|
||||
import { loadMetaData } from '@/lib/api/metadata'
|
||||
import { applyBrandingTheme } from '@/lib/theme/apply-branding'
|
||||
import { skipToFirstZone } from '@/lib/utils/skip-link'
|
||||
import { readMaterialTheme } from '@/lib/theme/material-theme'
|
||||
import { useTheme } from '@/lib/theme/theme-provider'
|
||||
import { recordAnalytics } from '@/lib/analytics'
|
||||
@@ -308,6 +309,7 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[9999] focus:inline-flex focus:items-center pointer-coarse:focus:min-h-11 focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-primary-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
data-qqq-id="skip-to-content"
|
||||
onClick={(event) => { if (skipToFirstZone()) event.preventDefault() }}
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
@@ -372,7 +374,7 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
/>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="relative flex-1 overflow-y-auto p-6" id="main-content" data-qqq-id="main-content">
|
||||
<main className="relative flex-1 overflow-y-auto p-6 focus:outline-none" id="main-content" tabIndex={-1} data-qqq-id="main-content">
|
||||
{metaLoading ? (
|
||||
<div
|
||||
className="flex items-center justify-center py-12"
|
||||
|
||||
@@ -183,7 +183,7 @@ function FieldValueContent({ field, record, allTables, navigateFrom, widgetMetaD
|
||||
if (target.external) {
|
||||
return (
|
||||
<a href={target.href} target={target.target} rel={target.target === '_blank' ? 'noopener noreferrer' : undefined}
|
||||
className={cn('inline-flex items-center gap-1 text-sm text-primary underline hover:text-primary/80', TOUCH_LINK, className)}
|
||||
className={cn('inline-flex items-center gap-1 text-sm text-link underline hover:text-link/80', TOUCH_LINK, className)}
|
||||
data-qqq-id={dataQqqId}>
|
||||
{String(value)}
|
||||
{target.target === '_blank' && <ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
@@ -191,7 +191,7 @@ function FieldValueContent({ field, record, allTables, navigateFrom, widgetMetaD
|
||||
</a>
|
||||
)
|
||||
}
|
||||
return <Link href={target.href} className={cn('text-sm text-primary underline hover:text-primary/80', TOUCH_LINK, className)}
|
||||
return <Link href={target.href} className={cn('text-sm text-link underline hover:text-link/80', TOUCH_LINK, className)}
|
||||
data-qqq-id={dataQqqId}>{String(value)}</Link>
|
||||
}
|
||||
return <PlainValue value={String(value)} dataQqqId={dataQqqId} className={className} />
|
||||
@@ -300,7 +300,7 @@ function FieldValueContent({ field, record, allTables, navigateFrom, widgetMetaD
|
||||
if (field.type === 'STRING' && isHttpUrl(strValue)) {
|
||||
return (
|
||||
<a href={strValue} target="_blank" rel="noopener noreferrer"
|
||||
className={cn('inline-flex items-center gap-1 text-sm text-primary hover:text-primary/80 hover:underline', TOUCH_LINK, className)}
|
||||
className={cn('inline-flex items-center gap-1 text-sm text-link hover:text-link/80 hover:underline', TOUCH_LINK, className)}
|
||||
data-qqq-id={dataQqqId}>
|
||||
{strValue}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
@@ -309,7 +309,7 @@ function FieldValueContent({ field, record, allTables, navigateFrom, widgetMetaD
|
||||
)
|
||||
}
|
||||
if (field.type === 'STRING' && isEmail(strValue)) {
|
||||
return <a href={`mailto:${strValue}`} className={cn('text-sm text-primary hover:text-primary/80 hover:underline', TOUCH_LINK, className)}
|
||||
return <a href={`mailto:${strValue}`} className={cn('text-sm text-link hover:text-link/80 hover:underline', TOUCH_LINK, className)}
|
||||
data-qqq-id={dataQqqId}>{strValue}</a>
|
||||
}
|
||||
return <PlainValue value={strValue} dataQqqId={dataQqqId} className={className} secure={secureValueAttributes(field)} />
|
||||
@@ -379,7 +379,7 @@ function RecordLink({ tableName, primaryKey, label, allTables, fromParams, dataQ
|
||||
}) {
|
||||
const link = (
|
||||
<Link href={`/app/${encodeURIComponent(tableName)}/${encodeURIComponent(primaryKey)}${fromParams}`}
|
||||
className={cn('text-sm text-primary hover:text-primary/80 hover:underline', TOUCH_LINK, className)} data-qqq-id={dataQqqId}>
|
||||
className={cn('text-sm text-link hover:text-link/80 hover:underline', TOUCH_LINK, className)} data-qqq-id={dataQqqId}>
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
@@ -406,13 +406,13 @@ function FileLinks({ url, fileName, dataQqqId, className, inline = true }: {
|
||||
<span className={cn('inline-flex flex-wrap items-center gap-2 text-sm', className)} data-qqq-id={dataQqqId}>
|
||||
<span className="text-foreground">{fileName}</span>
|
||||
{inline && (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className={cn('inline-flex items-center gap-1 text-primary underline hover:text-primary/80', TOUCH_LINK)}
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className={cn('inline-flex items-center gap-1 text-link underline hover:text-link/80', TOUCH_LINK)}
|
||||
data-qqq-id={`${dataQqqId}-open`}>
|
||||
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Open file<span className="sr-only">: {fileName} (opens in a new tab)</span>
|
||||
</a>
|
||||
)}
|
||||
<a href={inline ? attachmentUrl(url) : url} download={fileName} className={cn('inline-flex items-center gap-1 text-primary underline hover:text-primary/80', TOUCH_LINK)}
|
||||
<a href={inline ? attachmentUrl(url) : url} download={fileName} className={cn('inline-flex items-center gap-1 text-link underline hover:text-link/80', TOUCH_LINK)}
|
||||
data-qqq-id={`${dataQqqId}-download`}>
|
||||
<Download className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Download file<span className="sr-only">: {fileName}</span>
|
||||
@@ -462,7 +462,7 @@ function CodeViewer({ code, languageMode, fieldName, className }: { code: string
|
||||
}
|
||||
}
|
||||
|
||||
const buttonClass = cn('rounded px-2 py-0.5 text-primary hover:underline focus:outline-none focus:ring-2 focus:ring-ring', TOUCH_LINK)
|
||||
const buttonClass = cn('rounded px-2 py-0.5 text-link hover:underline focus:outline-none focus:ring-2 focus:ring-ring', TOUCH_LINK)
|
||||
return (
|
||||
<div className={cn('w-full space-y-1', className)} data-qqq-id={`field-value-${fieldName}`} data-language-mode={languageMode}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-1 text-xs text-muted-foreground">
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
// Tests for AppHome widget headings: every widget title is an h2 under the page's h1
|
||||
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
import type { QAppMetaData, QInstance, QWidgetMetaData } from '@/types'
|
||||
import { AppHome } from './AppHome'
|
||||
import { WidgetBlock } from './WidgetBlock'
|
||||
|
||||
// The real chrome (WidgetBlock) around a stub body, so the title element is the production one
|
||||
vi.mock('./ConnectedWidget', () => ({
|
||||
ConnectedWidget: ({ widgetMetaData }: { widgetMetaData: QWidgetMetaData }) => (
|
||||
<WidgetBlock widgetMetaData={widgetMetaData}><p>{widgetMetaData.label} body</p></WidgetBlock>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('AppHome widget headings', () => {
|
||||
it('renders widget titles as h2 directly under the h1', () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const registry = {
|
||||
one: { name: 'one', label: 'First Widget', type: 'table', hasPermission: true, isCard: true },
|
||||
two: { name: 'two', label: 'Second Widget', type: 'table', hasPermission: true, isCard: true },
|
||||
} as Record<string, QWidgetMetaData>
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<AppHome appMetaData={{ name: 'home', label: 'Home', widgets: ['one', 'two'], children: [], sections: [] } as unknown as QAppMetaData}
|
||||
instance={{ apps: {}, appTree: [], tables: {}, processes: {}, widgets: {} } as unknown as QInstance} widgetRegistry={registry} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('heading', { level: 2 }).map((heading) => heading.textContent)).toEqual(['First Widget', 'Second Widget'])
|
||||
expect(screen.queryAllByRole('heading', { level: 3 })).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -42,6 +42,7 @@ import { useWidgetAnchor } from '@/lib/hooks/use-widget-anchor'
|
||||
|
||||
import { HelpIcon } from '@/components/help/HelpIcon'
|
||||
import { ConnectedWidget } from './ConnectedWidget'
|
||||
import { WidgetHeadingLevelContext } from './widget-context'
|
||||
import { WIDGET_ANCHOR, widgetColumnClasses } from './widget-utils'
|
||||
|
||||
/** Props accepted by the AppHome component. */
|
||||
@@ -271,7 +272,10 @@ export function AppHome({ appMetaData, instance, widgetRegistry }: AppHomeProps)
|
||||
<section aria-label="Dashboard widgets" className="@container grid grid-cols-12 gap-5" data-qqq-id="widget-grid">
|
||||
{widgetItems.map((meta) => (
|
||||
<div key={meta.name} id={meta.name} className={`${widgetColumnClasses(meta.gridColumns, meta.defaultValues, meta.type)} ${WIDGET_ANCHOR}`} data-qqq-id={`widget-grid-item-${meta.name}`}>
|
||||
<ConnectedWidget widgetMetaData={meta} widgetRegistry={widgetRegistry} />
|
||||
{/* Widget titles are h2: directly under the page's h1 */}
|
||||
<WidgetHeadingLevelContext.Provider value={2}>
|
||||
<ConnectedWidget widgetMetaData={meta} widgetRegistry={widgetRegistry} />
|
||||
</WidgetHeadingLevelContext.Provider>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
@@ -60,7 +60,7 @@ export function ChildListRelatedPickers({ pickers, tableName, primaryKey, widget
|
||||
key={picker.name}
|
||||
type="button"
|
||||
onClick={() => { setStatus(''); setOpen(picker) }}
|
||||
className="text-sm font-medium text-primary underline-offset-2 hover:underline focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="text-sm font-medium text-link underline-offset-2 hover:underline focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
data-qqq-id={`child-record-picker-${widgetName}-${picker.name}`}
|
||||
>
|
||||
{picker.label}
|
||||
|
||||
@@ -98,7 +98,7 @@ export function ChildRecordListEditor({ widgetMetaData, data, table, onChange }:
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenForm({ rowIndex: null, defaults: newDefaults, disabled: lockedForNew })}
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm font-medium text-primary hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:min-h-11"
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm font-medium text-link hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:min-h-11"
|
||||
data-qqq-id={`child-record-add-${widgetName}`}
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
|
||||
@@ -443,7 +443,7 @@ export function ChildRecordListWidget({ widgetMetaData, data, recordContext, for
|
||||
type="button"
|
||||
onClick={() => setShowAllColumns((current) => !current)}
|
||||
aria-expanded={showAllColumns}
|
||||
className="rounded text-primary underline-offset-2 hover:underline focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="rounded text-link underline-offset-2 hover:underline focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
data-qqq-id={`child-record-columns-toggle-${widgetName}`}
|
||||
>
|
||||
{showAllColumns ? 'Fewer columns' : `+${hiddenCount} more column${hiddenCount === 1 ? '' : 's'}`}
|
||||
@@ -464,7 +464,7 @@ export function ChildRecordListWidget({ widgetMetaData, data, recordContext, for
|
||||
// a plain anchor: the record view reacts to the hash change (Next's Link would not fire it)
|
||||
<a
|
||||
href={addChildHref(data, table.name, recordContext?.record?.values)}
|
||||
className="text-sm font-medium text-primary underline-offset-2 hover:underline focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="text-sm font-medium text-link underline-offset-2 hover:underline focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
data-qqq-id={`child-record-add-${widgetName}`}
|
||||
>
|
||||
Add new {tableLabel}
|
||||
@@ -507,7 +507,7 @@ export function ChildRecordListWidget({ widgetMetaData, data, recordContext, for
|
||||
return (
|
||||
<td key={column.name} className="px-2 py-1.5 align-top text-foreground" data-qqq-id={`child-record-cell-${widgetName}-${column.name}`}>
|
||||
{columnIndex === 0 && href && !isRestricted(fields[column.name]) && !hasFilePreview(fields[column.name]) ? (
|
||||
<Link href={href} data-row-link="" className="text-primary underline-offset-2 hover:underline focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
<Link href={href} data-row-link="" className="text-link underline-offset-2 hover:underline focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
{text || pkText}
|
||||
</Link>
|
||||
) : (
|
||||
@@ -536,7 +536,7 @@ export function ChildRecordListWidget({ widgetMetaData, data, recordContext, for
|
||||
{viewAllHref && (
|
||||
<Link
|
||||
href={viewAllHref}
|
||||
className="text-primary underline-offset-2 hover:underline focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="text-link underline-offset-2 hover:underline focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
data-qqq-id={`child-record-view-all-${widgetName}`}
|
||||
>
|
||||
View All
|
||||
|
||||
@@ -184,7 +184,7 @@ function DestinationRow({
|
||||
</span>
|
||||
<Link
|
||||
href={`/app/${encodeURIComponent(publisher.name)}${publisher.kind === 'PROCESS' ? '/dev' : ''}`}
|
||||
className="font-medium text-primary underline-offset-2 hover:underline focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="font-medium text-link underline-offset-2 hover:underline focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{publisher.name}
|
||||
</Link>
|
||||
|
||||
@@ -292,7 +292,7 @@ function VariantFilterSetupGrid({ table, filter, onFilterChange, columns, onColu
|
||||
</button>}
|
||||
{editable ? <DensitySelector density={density} onSelect={setDensity} /> :
|
||||
<a href={openInNewWindowHref(table.name, toBackendFilter(table, filter))} target="_blank" rel="noopener noreferrer"
|
||||
data-qqq-id={`filter-preview-open-${widgetName}`} className="inline-flex min-h-11 items-center text-sm text-primary underline">Open in new window</a>}
|
||||
data-qqq-id={`filter-preview-open-${widgetName}`} className="inline-flex min-h-11 items-center text-sm text-link underline">Open in new window</a>}
|
||||
</div>
|
||||
{showColumns && !hideColumns && <div className="border-b border-border p-3"><ColumnConfig tableMetaData={selectionTable}
|
||||
columnVisibility={columns.columnVisibility} columnOrder={columns.columnOrder}
|
||||
@@ -462,7 +462,7 @@ function EditorDialog({ table, data, values, widgetMetaData, widgetName, onCance
|
||||
</div>
|
||||
))}
|
||||
{sorts.length > 0 && offeredFields.some((field) => !sorts.some((sort) => sort.fieldName === field.name)) && <button type="button" onClick={addSort}
|
||||
className="rounded px-2 py-2 text-sm text-primary hover:underline" data-qqq-id={`filter-editor-add-sort-${widgetName}`}>+ Add sort</button>}
|
||||
className="rounded px-2 py-2 text-sm text-link hover:underline" data-qqq-id={`filter-editor-add-sort-${widgetName}`}>+ Add sort</button>}
|
||||
</div>}
|
||||
</> : <ColumnConfig tableMetaData={selectionTable} columnVisibility={columns.columnVisibility} columnOrder={columns.columnOrder}
|
||||
onVisibilityChange={(visibility) => setColumns((current) => ({ ...current, columnVisibility: visibility }))}
|
||||
@@ -472,7 +472,7 @@ function EditorDialog({ table, data, values, widgetMetaData, widgetName, onCance
|
||||
columns={columns} onColumnsChange={setColumns} api={api} widgetName={widgetName} editable hideColumns={data?.hideColumns} omittedJoins={data?.omitExposedJoins} onEditFilter={() => { setTab('filters'); setMode('advanced') }} /></section>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border p-4">
|
||||
<a href={openInNewWindowHref(table.name, backend)} target="_blank" rel="noopener noreferrer" className="text-sm text-primary underline">Open in new window</a>
|
||||
<a href={openInNewWindowHref(table.name, backend)} target="_blank" rel="noopener noreferrer" className="text-sm text-link underline">Open in new window</a>
|
||||
<div className="flex gap-2"><button type="button" onClick={onCancel} className="rounded border border-border px-4 py-2 text-sm">Cancel</button>
|
||||
<button type="button" disabled={disabled} onClick={() => onSave(filter, columns)} data-qqq-id={`filter-editor-save-${widgetName}`} className="rounded bg-primary px-4 py-2 text-sm text-primary-foreground disabled:opacity-50">OK</button></div>
|
||||
</div>
|
||||
|
||||
@@ -264,7 +264,7 @@ function PivotTableSetupEditor({ widgetMetaData, formContext }: { widgetMetaData
|
||||
onClick={openEditor}
|
||||
disabled={editDisabled}
|
||||
data-qqq-id={`pivot-add-${noun}-${widgetName}`}
|
||||
className="inline-flex min-h-9 items-center gap-1.5 rounded-md px-2 text-sm font-medium text-primary hover:underline focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:text-muted-foreground disabled:no-underline pointer-coarse:min-h-11"
|
||||
className="inline-flex min-h-9 items-center gap-1.5 rounded-md px-2 text-sm font-medium text-link hover:underline focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:text-muted-foreground disabled:no-underline pointer-coarse:min-h-11"
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
Add new {noun}
|
||||
|
||||
@@ -175,7 +175,7 @@ export function ProcessRowBuilderEditor({ widgetMetaData, data, fields, host, on
|
||||
type="button"
|
||||
onClick={addRow}
|
||||
disabled={host.disabled}
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm font-medium text-primary hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:min-h-11"
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm font-medium text-link hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:min-h-11"
|
||||
data-qqq-id={`button-row-builder-add-${widgetName}`}
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
|
||||
@@ -165,7 +165,7 @@ export function QqqStepperWidget({ widgetMetaData, data }: WidgetComponentProps<
|
||||
{step.label}
|
||||
</span>
|
||||
{state === 'current' && step.linkURL && (
|
||||
<WidgetLink href={step.linkURL} className="text-xs text-primary" qqqId={`stepper-link-${name}-${index}`}>{step.linkText ?? step.linkURL}</WidgetLink>
|
||||
<WidgetLink href={step.linkURL} className="text-xs text-link" qqqId={`stepper-link-${name}-${index}`}>{step.linkText ?? step.linkURL}</WidgetLink>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
|
||||
@@ -222,7 +222,7 @@ function QqqTable({ widgetMetaData, table, suffix = '', ownChrome = false }: Tab
|
||||
const header = (table.label || table.linkURL || (ownChrome && widgetMetaData.showExportButton)) ? (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
{table.label && <h4 className="text-sm font-semibold text-foreground">{table.label}</h4>}
|
||||
{table.linkURL && <WidgetLink href={table.linkURL} className="text-sm text-primary">({table.linkText ?? table.linkURL})</WidgetLink>}
|
||||
{table.linkURL && <WidgetLink href={table.linkURL} className="text-sm text-link">({table.linkText ?? table.linkURL})</WidgetLink>}
|
||||
{ownChrome && widgetMetaData.showExportButton && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -180,7 +180,7 @@ export function RecordGridWidget({ data, widgetName }: RecordGridWidgetProps) {
|
||||
<div className="mt-2 text-center" data-qqq-id={`widget-grid-view-all-${widgetName}`}>
|
||||
<a
|
||||
href={`/app/${tableName}`}
|
||||
className="text-sm text-primary underline hover:text-primary/80"
|
||||
className="text-sm text-link underline hover:text-link/80"
|
||||
>
|
||||
View all {records.length} records →
|
||||
</a>
|
||||
|
||||
@@ -24,7 +24,7 @@ import userEvent from '@testing-library/user-event'
|
||||
import type { QWidgetMetaData } from '@/types'
|
||||
import { QContext, type QContextType } from '@/lib/context/q-context'
|
||||
import { WidgetBlock, initialCollapsibleOpenState } from './WidgetBlock'
|
||||
import { WidgetMetaDataContext } from './widget-context'
|
||||
import { WidgetHeadingLevelContext, WidgetMetaDataContext } from './widget-context'
|
||||
|
||||
const base: QWidgetMetaData = { name: 'accChrome', label: 'Owned Chrome', type: 'html', hasPermission: true, isCard: true }
|
||||
const collapsible: QWidgetMetaData = {
|
||||
@@ -130,6 +130,18 @@ describe('WidgetBlock labels', () => {
|
||||
expect(screen.getByRole('heading', { level: 3, name: 'Owned Label' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the title as h2 where the page provides level 2 (an app home), and keeps nested widgets at h3', () => {
|
||||
const NestedTitle = () => <WidgetBlock widgetMetaData={{ ...base, name: 'accNested', label: 'Nested' }}><p>Inner</p></WidgetBlock>
|
||||
render(
|
||||
<WidgetHeadingLevelContext.Provider value={2}>
|
||||
<WidgetBlock widgetMetaData={base}><NestedTitle /></WidgetBlock>
|
||||
</WidgetHeadingLevelContext.Provider>
|
||||
)
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Owned Chrome' })).toHaveAttribute('data-qqq-id', 'widget-label-accChrome')
|
||||
expect(screen.getByRole('heading', { level: 3, name: 'Nested' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('heading', { level: 3, name: 'Owned Chrome' })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the label help, and its key in help-authoring mode', () => {
|
||||
const helped = { ...base, helpContent: { label: [{ content: 'Owned <b>help</b>', format: 'HTML' as const }] } }
|
||||
const { unmount } = render(<WidgetBlock widgetMetaData={helped}><p>Body</p></WidgetBlock>)
|
||||
|
||||
@@ -44,7 +44,7 @@ import { SafeHtml } from './SafeHtml'
|
||||
import { WidgetDropdownMenu } from './WidgetDropdownMenu'
|
||||
import { WidgetErrorBoundary } from './WidgetErrorBoundary'
|
||||
import { WidgetIconTile } from './WidgetIcon'
|
||||
import { WidgetMetaDataContext } from './widget-context'
|
||||
import { WidgetHeadingLevelContext, WidgetMetaDataContext, useWidgetHeadingLevel } from './widget-context'
|
||||
import { widgetSlotHelp } from './widget-utils'
|
||||
|
||||
export type { WidgetDropdownControl } from './WidgetDropdownMenu'
|
||||
@@ -128,6 +128,8 @@ export function WidgetBlock({
|
||||
exportMessage, exportControl, dropdowns, onDropdownChange, children, className, bare = false, hideReload = false,
|
||||
}: WidgetBlockProps) {
|
||||
const { name } = widgetMetaData
|
||||
// h2 on an app home (directly under the page's h1), h3 for a widget nested in another
|
||||
const Heading = `h${useWidgetHeadingLevel()}` as const
|
||||
const bodyId = useId()
|
||||
const isCard = widgetMetaData.isCard !== false
|
||||
const processHost = useCompositeHost()
|
||||
@@ -162,6 +164,7 @@ export function WidgetBlock({
|
||||
|
||||
const body = (
|
||||
<WidgetMetaDataContext.Provider value={widgetMetaData}>
|
||||
<WidgetHeadingLevelContext.Provider value={3}>
|
||||
{isLoading ? (
|
||||
<WidgetSkeleton />
|
||||
) : isError ? (
|
||||
@@ -177,6 +180,7 @@ export function WidgetBlock({
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</WidgetHeadingLevelContext.Provider>
|
||||
</WidgetMetaDataContext.Provider>
|
||||
)
|
||||
|
||||
@@ -187,7 +191,7 @@ export function WidgetBlock({
|
||||
const labelElement = label ? (labelAsTitle ? (
|
||||
<h2 className="text-2xl font-bold tracking-tight text-card-foreground" data-qqq-id={`widget-label-${name}`} data-page-title="true">{label}</h2>
|
||||
) : (
|
||||
<h3 className={cn('font-semibold text-card-foreground', compact ? 'text-sm' : 'text-base')} data-qqq-id={`widget-label-${name}`}>{label}</h3>
|
||||
<Heading className={cn('font-semibold text-card-foreground', compact ? 'text-sm' : 'text-base')} data-qqq-id={`widget-label-${name}`}>{label}</Heading>
|
||||
)) : null
|
||||
|
||||
/**
|
||||
@@ -349,7 +353,7 @@ function WidgetErrorState({ error, onReload, widgetName }: { error: Error | null
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReload}
|
||||
className="text-xs text-primary underline hover:text-primary/80 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="text-xs text-link underline hover:text-link/80 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
data-qqq-id={`button-widget-retry-inline-${widgetName}`}
|
||||
>
|
||||
Retry
|
||||
|
||||
@@ -70,6 +70,12 @@ describe('WidgetDropdownMenu combobox', () => {
|
||||
expect(onChange).toHaveBeenLastCalledWith(null)
|
||||
})
|
||||
|
||||
it('gives the clear button a target of at least 24 x 24 px (WCAG 2.2 target size)', () => {
|
||||
renderMenu({ value: 'alpha' })
|
||||
const clear = screen.getByRole('button', { name: 'Clear Choice' })
|
||||
expect(clear).toHaveClass('min-h-6', 'min-w-6')
|
||||
})
|
||||
|
||||
it('hides the clear button with disableClearable, sizes to the metadata width and shows the start icon', () => {
|
||||
renderMenu({ value: 'alpha', disableClearable: true, width: 300, startIconName: 'star' })
|
||||
expect(screen.queryByRole('button', { name: 'Clear Choice' })).toBeNull()
|
||||
|
||||
@@ -81,7 +81,7 @@ export const DEFAULT_DROPDOWN_WIDTH = 225
|
||||
export const CUSTOM_TIMEFRAME_DEBOUNCE_MS = 500
|
||||
|
||||
/** Classes for a small icon button that is a 44 px target on touch screens. */
|
||||
const ICON_BUTTON = 'inline-flex flex-shrink-0 items-center justify-center rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40 pointer-coarse:min-h-11 pointer-coarse:min-w-11'
|
||||
const ICON_BUTTON = 'inline-flex flex-shrink-0 items-center justify-center rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40 min-h-6 min-w-6 pointer-coarse:min-h-11 pointer-coarse:min-w-11'
|
||||
|
||||
/**
|
||||
* Renders one widget header dropdown.
|
||||
@@ -137,7 +137,7 @@ function DatePickerDropdown({ widgetName, control, onChange }: WidgetDropdownMen
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => choose(new Date())}
|
||||
className="rounded px-2 py-1 text-sm font-medium text-primary hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:min-h-11 pointer-coarse:min-w-11"
|
||||
className="rounded px-2 py-1 text-sm font-medium text-link hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:min-h-11 pointer-coarse:min-w-11"
|
||||
aria-label={`Today for ${label}`}
|
||||
data-qqq-id={`button-widget-dropdown-today-${id}`}
|
||||
>
|
||||
|
||||
@@ -55,7 +55,7 @@ export function WidgetHeaderLinkButton({ label, onClick, disabled = false, disab
|
||||
disabled={disabled}
|
||||
data-qqq-id={qqqId}
|
||||
className={cn(
|
||||
'inline-flex min-h-9 items-center rounded-md px-2 text-sm font-medium text-primary hover:underline',
|
||||
'inline-flex min-h-9 items-center rounded-md px-2 text-sm font-medium text-link hover:underline',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring pointer-coarse:min-h-11',
|
||||
'disabled:cursor-not-allowed disabled:text-muted-foreground disabled:no-underline'
|
||||
)}
|
||||
|
||||
@@ -145,7 +145,7 @@ function BridgeButton({ label, onClick, extra }: { label: string; onClick: () =>
|
||||
'inline-flex w-full items-center justify-center rounded-md px-4 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
variant === 'filled' && 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
variant === 'outlined' && 'border border-input bg-card text-foreground hover:bg-accent',
|
||||
variant === 'text' && 'text-primary hover:bg-accent'
|
||||
variant === 'text' && 'text-link hover:bg-accent'
|
||||
)}
|
||||
data-qqq-id="bridge-button"
|
||||
>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import { createContext } from 'react'
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
import type { QWidgetMetaData } from '@/types'
|
||||
|
||||
@@ -29,3 +29,22 @@ import type { QWidgetMetaData } from '@/types'
|
||||
* tooltip, as Material's `BlockElementWrapper` does.
|
||||
*/
|
||||
export const WidgetMetaDataContext = createContext<QWidgetMetaData | null>(null)
|
||||
|
||||
/** Heading levels a widget title can render at. */
|
||||
export type WidgetHeadingLevel = 2 | 3
|
||||
|
||||
/**
|
||||
* The heading level of the title of the next widget chrome. An app home provides 2 (widget titles sit
|
||||
* directly under the page's h1); a widget's own body resets it to 3, so a nested widget (a parent
|
||||
* widget's tab, a record-view widget) stays one level below its container. Defaults to 3.
|
||||
*/
|
||||
export const WidgetHeadingLevelContext = createContext<WidgetHeadingLevel>(3)
|
||||
|
||||
/**
|
||||
* Reads the heading level the next widget title renders at.
|
||||
*
|
||||
* @returns 2 on an app home, else 3.
|
||||
*/
|
||||
export function useWidgetHeadingLevel(): WidgetHeadingLevel {
|
||||
return useContext(WidgetHeadingLevelContext)
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { applyBrandingTheme, isSafeAccentColor, isSafeImageSource } from './apply-branding'
|
||||
import { applyBrandingTheme, contrastRatio, isSafeAccentColor, isSafeImageSource, linkTextColors, onAccentColor } from './apply-branding'
|
||||
|
||||
afterEach(() => {
|
||||
document.documentElement.removeAttribute('style')
|
||||
@@ -63,4 +63,44 @@ describe('apply-branding', () => {
|
||||
expect(document.querySelector<HTMLLinkElement>("link[rel='icon']")?.getAttribute('href')).toBe('/favicon.ico')
|
||||
expect(applyBrandingTheme(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('link text colors', () => {
|
||||
const hex = (color: string) => [1, 3, 5].map((i) => parseInt(color.slice(i, i + 2), 16))
|
||||
const LIGHT_SURFACES = ['#ffffff', '#f0f2f6']
|
||||
const DARK_SURFACES = ['#181a24', '#1e2130']
|
||||
|
||||
it.each(['#059669', '#6366f1', '#34d399', '#facc15', '#0062ff', '#ffffff', '#000000'])('reaches 4.5:1 in both themes for the accent %s', (accent) => {
|
||||
const link = linkTextColors(accent)!
|
||||
for (const surface of LIGHT_SURFACES) expect(contrastRatio(hex(link.light), hex(surface))).toBeGreaterThanOrEqual(4.5)
|
||||
for (const surface of DARK_SURFACES) expect(contrastRatio(hex(link.dark), hex(surface))).toBeGreaterThanOrEqual(4.5)
|
||||
})
|
||||
|
||||
it('keeps an accent that is already readable', () => {
|
||||
expect(linkTextColors('#1d4ed8')).toEqual({ light: '#1d4ed8', dark: expect.any(String) })
|
||||
})
|
||||
|
||||
it('reads rgb() and short hex accents, and skips formats it cannot parse', () => {
|
||||
expect(linkTextColors('rgb(5, 150, 105)')).toEqual(linkTextColors('#059669'))
|
||||
expect(linkTextColors('#0a9')).toEqual(linkTextColors('#00aa99'))
|
||||
expect(linkTextColors('hsl(160, 94%, 30%)')).toBeNull()
|
||||
})
|
||||
|
||||
it('picks the text color for accent-filled surfaces: white when it reads, else black', () => {
|
||||
expect(onAccentColor('#1d4ed8')).toBe('#ffffff')
|
||||
expect(onAccentColor('#059669')).toBe('#000000')
|
||||
expect(onAccentColor('#facc15')).toBe('#000000')
|
||||
expect(onAccentColor('hsl(160, 94%, 30%)')).toBeNull()
|
||||
applyBrandingTheme({ accentColor: '#059669' })
|
||||
expect(document.documentElement.style.getPropertyValue('--color-primary-foreground')).toBe('#000000')
|
||||
})
|
||||
|
||||
it('sets the derived colors that --color-link reads', () => {
|
||||
applyBrandingTheme({ accentColor: '#059669' })
|
||||
const style = document.documentElement.style
|
||||
expect(style.getPropertyValue('--color-primary')).toBe('#059669')
|
||||
expect(style.getPropertyValue('--qqq-link-text-light')).toBe(linkTextColors('#059669')!.light)
|
||||
expect(style.getPropertyValue('--qqq-link-text-dark')).toBe(linkTextColors('#059669')!.dark)
|
||||
expect(style.getPropertyValue('--qqq-link-text-light')).not.toBe('#059669')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -27,6 +27,104 @@ const ACCENT_COLOR_RE = /^#[0-9a-fA-F]{3,8}$|^rgb\(|^rgba\(|^hsl\(|^hsla\(/
|
||||
/** Custom properties that carry the primary accent color (including the Material `--qqq-primary-color`). */
|
||||
const ACCENT_PROPERTIES = ['--qqq-accent-color', '--qqq-primary-color', '--color-primary', '--primary', '--ring', '--qqq-sidebar-active-bg']
|
||||
|
||||
/** Background of the muted surface in the light theme: the darkest surface text may sit on. */
|
||||
const LIGHT_SURFACE = '#f0f2f6'
|
||||
|
||||
/** Background of the muted surface in the dark theme: the lightest surface text may sit on. */
|
||||
const DARK_SURFACE = '#1e2130'
|
||||
|
||||
/** Contrast the derived link text color must reach on its surface (WCAG AA is 4.5; a little margin for rounding). */
|
||||
const LINK_CONTRAST = 4.6
|
||||
|
||||
/**
|
||||
* Parses a `#rgb`, `#rrggbb` or `rgb()/rgba()` color.
|
||||
*
|
||||
* @param color - The CSS color.
|
||||
* @returns Its red, green and blue channels (0-255), or `null` for any other format.
|
||||
*/
|
||||
function parseRgb(color: string): [number, number, number] | null {
|
||||
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(color.trim())
|
||||
if (hex) {
|
||||
const digits = hex[1].length === 3 ? hex[1].replace(/./g, (c) => c + c) : hex[1].slice(0, 6)
|
||||
return [0, 2, 4].map((i) => parseInt(digits.slice(i, i + 2), 16)) as [number, number, number]
|
||||
}
|
||||
const rgb = /^rgba?\(\s*(\d{1,3})[\s,]+(\d{1,3})[\s,]+(\d{1,3})/i.exec(color.trim())
|
||||
return rgb ? [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])] : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative luminance of a color (WCAG 2).
|
||||
*
|
||||
* @param rgb - Red, green and blue channels (0-255).
|
||||
* @returns The luminance, 0 (black) to 1 (white).
|
||||
*/
|
||||
function luminance(rgb: readonly number[]): number {
|
||||
const [r, g, b] = rgb.map((channel) => {
|
||||
const value = Math.max(0, Math.min(255, channel)) / 255
|
||||
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
|
||||
})
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
}
|
||||
|
||||
/**
|
||||
* WCAG contrast ratio of two colors.
|
||||
*
|
||||
* @param a - First color channels.
|
||||
* @param b - Second color channels.
|
||||
* @returns The ratio, 1 to 21.
|
||||
*/
|
||||
export function contrastRatio(a: readonly number[], b: readonly number[]): number {
|
||||
const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x)
|
||||
return (light + 0.05) / (dark + 0.05)
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a color toward black or white, in the smallest steps, until it reaches {@link LINK_CONTRAST}
|
||||
* on a surface. A color that already does is returned unchanged.
|
||||
*
|
||||
* @param rgb - The accent channels.
|
||||
* @param surface - The surface channels the text sits on.
|
||||
* @param target - What to mix in: black (0) or white (255).
|
||||
* @returns The adjusted color as `#rrggbb`.
|
||||
*/
|
||||
function mixUntilReadable(rgb: [number, number, number], surface: readonly number[], target: 0 | 255): string {
|
||||
let result = rgb
|
||||
for (let step = 0; step <= 50 && contrastRatio(result, surface) < LINK_CONTRAST; step += 1) {
|
||||
const t = step / 50
|
||||
result = rgb.map((channel) => Math.round(channel + (target - channel) * t)) as [number, number, number]
|
||||
}
|
||||
return `#${result.map((channel) => channel.toString(16).padStart(2, '0')).join('')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the link-text colors of an accent: the accent itself when it is readable, otherwise
|
||||
* darkened (light theme) or lightened (dark theme) until it reaches 4.5:1 on the theme's surfaces.
|
||||
*
|
||||
* @param accent - The branding accent color.
|
||||
* @returns The light-theme and dark-theme text colors, or `null` when the color format is not parsed
|
||||
* (the stylesheet then derives them from the accent).
|
||||
*/
|
||||
export function linkTextColors(accent: string): { light: string; dark: string } | null {
|
||||
const rgb = parseRgb(accent)
|
||||
if (!rgb) return null
|
||||
const light = mixUntilReadable(rgb, parseRgb(LIGHT_SURFACE)!, 0)
|
||||
const dark = mixUntilReadable(rgb, parseRgb(DARK_SURFACE)!, 255)
|
||||
return { light, dark }
|
||||
}
|
||||
|
||||
/**
|
||||
* Text color for a surface filled with the accent (buttons, the active sidebar item, selected
|
||||
* tabs): white when that reaches 4.5:1, else black (one of the two always does).
|
||||
*
|
||||
* @param accent - The branding accent color.
|
||||
* @returns `#ffffff` or `#000000`, or `null` when the color format is not parsed.
|
||||
*/
|
||||
export function onAccentColor(accent: string): '#ffffff' | '#000000' | null {
|
||||
const rgb = parseRgb(accent)
|
||||
if (!rgb) return null
|
||||
return contrastRatio([255, 255, 255], rgb) >= 4.5 ? '#ffffff' : '#000000'
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a branding color may be applied to CSS custom properties.
|
||||
*
|
||||
@@ -68,6 +166,14 @@ export function applyBrandingTheme(branding: QLoginBranding | undefined): string
|
||||
if (isSafeAccentColor(branding.accentColor)) {
|
||||
for (const property of ACCENT_PROPERTIES) root.style.setProperty(property, branding.accentColor)
|
||||
applied = branding.accentColor
|
||||
// Link text must stay readable whatever the brand color is (--color-link, see qqq-theme.css).
|
||||
const onAccent = onAccentColor(branding.accentColor)
|
||||
if (onAccent) root.style.setProperty('--color-primary-foreground', onAccent)
|
||||
const link = linkTextColors(branding.accentColor)
|
||||
if (link) {
|
||||
root.style.setProperty('--qqq-link-text-light', link.light)
|
||||
root.style.setProperty('--qqq-link-text-dark', link.dark)
|
||||
}
|
||||
}
|
||||
if (isSafeAccentColor(branding.accentColorLight)) {
|
||||
root.style.setProperty('--qqq-accent-color-light', branding.accentColorLight)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
// Tests for the skip link target: the first zone of the page, made focusable
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { findSkipTarget, skipToFirstZone } from './skip-link'
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('skip link', () => {
|
||||
it('targets the first widget of an app home and moves focus to it', () => {
|
||||
document.body.innerHTML = `<main id="main-content" tabindex="-1">
|
||||
<h1>Home</h1>
|
||||
<section data-qqq-id="widget-grid">
|
||||
<div data-qqq-id="widget-grid-item-first"><h2>First</h2></div>
|
||||
<div data-qqq-id="widget-grid-item-second"><h2>Second</h2></div>
|
||||
</section></main>`
|
||||
const first = document.querySelector<HTMLElement>('[data-qqq-id="widget-grid-item-first"]')!
|
||||
expect(findSkipTarget()).toBe(first)
|
||||
expect(skipToFirstZone()).toBe(true)
|
||||
expect(first).toHaveAttribute('tabindex', '-1')
|
||||
expect(document.activeElement).toBe(first)
|
||||
})
|
||||
|
||||
it('falls back to the main region when the page has no widgets (and main is focusable)', () => {
|
||||
document.body.innerHTML = '<main id="main-content" tabindex="-1"><h1>Dashboard</h1></main>'
|
||||
const main = document.getElementById('main-content')!
|
||||
expect(findSkipTarget()).toBe(main)
|
||||
expect(skipToFirstZone()).toBe(true)
|
||||
expect(document.activeElement).toBe(main)
|
||||
})
|
||||
|
||||
it('does nothing without a main region', () => {
|
||||
document.body.innerHTML = '<div>no main</div>'
|
||||
expect(findSkipTarget()).toBeNull()
|
||||
expect(skipToFirstZone()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* @file skip-link — where the "Skip to main content" link moves keyboard focus: the first zone of
|
||||
* the page (the first widget of an app home), else the main region itself.
|
||||
*/
|
||||
|
||||
import { MAIN_CONTENT_ID } from './scroll-to-top'
|
||||
|
||||
/** Selector of the first zone of a page: the first widget of an app home. */
|
||||
const FIRST_ZONE_SELECTOR = '[data-qqq-id^="widget-grid-item-"]'
|
||||
|
||||
/**
|
||||
* Finds the element the skip link targets: the first widget in `<main>`, else `<main>`.
|
||||
*
|
||||
* @returns The element, or `null` when the page has no main region.
|
||||
*/
|
||||
export function findSkipTarget(): HTMLElement | null {
|
||||
if (typeof document === 'undefined') return null
|
||||
const main = document.getElementById(MAIN_CONTENT_ID)
|
||||
return main?.querySelector<HTMLElement>(FIRST_ZONE_SELECTOR) ?? main
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves focus to the skip target. The target is made programmatically focusable
|
||||
* (`tabindex="-1"`) when it is not already, so focus really moves and the next Tab continues
|
||||
* from there.
|
||||
*
|
||||
* @returns True when focus moved.
|
||||
*/
|
||||
export function skipToFirstZone(): boolean {
|
||||
const target = findSkipTarget()
|
||||
if (!target) return false
|
||||
if (!target.hasAttribute('tabindex')) target.setAttribute('tabindex', '-1')
|
||||
target.focus()
|
||||
target.scrollIntoView?.({ block: 'start' })
|
||||
return true
|
||||
}
|
||||
@@ -100,6 +100,8 @@
|
||||
--color-primary-foreground: #ffffff;
|
||||
/* Text can adapt to dark surfaces independently of branded button fills. */
|
||||
--text-color-primary: var(--color-primary);
|
||||
/* Accent-colored text (links) that keeps 4.5:1 on cards and muted surfaces in both themes. */
|
||||
--color-link: var(--qqq-link-text);
|
||||
|
||||
/* Neutral surfaces */
|
||||
--color-secondary: #f0f2f6;
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
automatically picks up runtime theme overrides applied to the --color-* tokens. */
|
||||
--qqq-primary-color: var(--color-primary);
|
||||
--qqq-accent-color: var(--color-primary);
|
||||
/* Link text on card and muted surfaces: 4.5:1 or more. applyBrandingTheme sets
|
||||
--qqq-link-text-light/-dark from the brand accent; without them the accent is used (light)
|
||||
or lightened (dark). */
|
||||
--qqq-link-text: var(--qqq-link-text-light, var(--color-primary));
|
||||
--qqq-sidebar-active-bg: var(--color-primary);
|
||||
|
||||
/* === Brand Colors (fallback literals — used when --color-* tokens are not yet resolved) === */
|
||||
@@ -197,6 +201,7 @@
|
||||
--color-primary: #2563eb;
|
||||
--color-primary-foreground: #ffffff;
|
||||
--text-color-primary: color-mix(in srgb, var(--color-primary) 40%, white);
|
||||
--qqq-link-text: var(--qqq-link-text-dark, color-mix(in srgb, var(--color-primary) 60%, white));
|
||||
--color-secondary: #1e2130;
|
||||
--color-secondary-foreground: #f0f2f5;
|
||||
--color-muted: #1e2130;
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
// E2E: an app home with table widgets passes axe (color contrast, target size, heading order) with a
|
||||
// brand accent that is too light for text (#059669: 3.8:1 on white), in the light and dark themes;
|
||||
// widget titles are h2 under the page h1; and the skip link moves focus to the first widget.
|
||||
|
||||
import AxeBuilder from '@axe-core/playwright'
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { METADATA, setupApiMocks } from './api-mocks'
|
||||
|
||||
const WIDTHS = [390, 1440]
|
||||
|
||||
const TABLE = {
|
||||
type: 'table',
|
||||
linkURL: '/app/person',
|
||||
linkText: 'View all',
|
||||
columns: [{ header: 'Order', accessor: 'order' }, { header: 'Status', accessor: 'status' }],
|
||||
rows: [{ order: 'SO-100', status: 'Shipped' }, { order: 'SO-101', status: 'Awaiting fulfilment' }],
|
||||
dropdownNameList: ['timeframe'],
|
||||
dropdownLabelList: ['Range'],
|
||||
dropdownDataList: [[{ id: 'last7', label: 'Last 7 days' }, { id: 'last30', label: 'Last 30 days' }]],
|
||||
dropdownDefaultValueList: ['last7'],
|
||||
}
|
||||
|
||||
const WIDGETS = {
|
||||
openOrders: { name: 'openOrders', label: 'Open Orders', type: 'table', gridColumns: 6, dropdowns: [{ name: 'timeframe', label: 'Range', type: 'PVS' }] },
|
||||
returns: { name: 'returns', label: 'Returns', type: 'table', gridColumns: 6 },
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves an app home (`a11yLab`) of two table widgets, branded with the kof22 accent color.
|
||||
*
|
||||
* @param page - The page.
|
||||
*/
|
||||
async function setupHome(page: Page) {
|
||||
await setupApiMocks(page)
|
||||
const metaData = {
|
||||
...METADATA,
|
||||
branding: { ...METADATA.branding, accentColor: '#059669' },
|
||||
apps: { ...METADATA.apps, a11yLab: { name: 'a11yLab', label: 'A11y Lab', iconName: 'dashboard', widgets: Object.keys(WIDGETS), children: [], sections: [] } },
|
||||
appTree: [...METADATA.appTree, { name: 'a11yLab', label: 'A11y Lab', type: 'APP', iconName: 'dashboard', children: [] }],
|
||||
widgets: Object.fromEntries(Object.entries(WIDGETS).map(([name, widget]) => [name, { isCard: true, hasPermission: true, ...widget }])),
|
||||
}
|
||||
await page.route('**/qqq/v1/metaData**', (route) => {
|
||||
if (new URL(route.request().url()).pathname.endsWith('/metaData/authentication')) return route.fallback()
|
||||
return route.fulfill({ contentType: 'application/json', body: JSON.stringify(metaData) })
|
||||
})
|
||||
await page.route('**/qqq/v1/widget/**', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify(TABLE) }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Serious and critical axe violations of the page (contrast and target size included), summarized.
|
||||
*
|
||||
* @param page - The page.
|
||||
* @returns One line per violation: rule, impact and its first selectors.
|
||||
*/
|
||||
async function seriousViolations(page: Page): Promise<string[]> {
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa'])
|
||||
.analyze()
|
||||
return results.violations
|
||||
.filter((violation) => violation.impact === 'serious' || violation.impact === 'critical')
|
||||
.map((violation) => `${violation.id} (${violation.impact}): ${violation.nodes.slice(0, 3).map((node) => node.target.join(' ')).join(' | ')}`)
|
||||
}
|
||||
|
||||
test.describe('App home accessibility', () => {
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
for (const width of WIDTHS) {
|
||||
test(`no serious axe violations on a table-widget home, ${theme} theme at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
await page.addInitScript((value) => { try { window.localStorage.setItem('qqq-dark-mode', value) } catch { /* storage unavailable */ } }, String(theme === 'dark'))
|
||||
await setupHome(page)
|
||||
await page.goto('/app/a11yLab')
|
||||
await expect(page.locator('[data-qqq-id="table-widget-openOrders"] tbody tr, [data-qqq-id="table-widget-openOrders"] [role="row"]').first()).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: '(View all)' }).first()).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Clear Range' }).first()).toBeVisible()
|
||||
expect(await seriousViolations(page)).toEqual([])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
test('the clear control is at least 24 x 24 px', async ({ page }) => {
|
||||
await setupHome(page)
|
||||
await page.goto('/app/a11yLab')
|
||||
const box = await page.getByRole('button', { name: 'Clear Range' }).first().boundingBox()
|
||||
expect(box!.width).toBeGreaterThanOrEqual(24)
|
||||
expect(box!.height).toBeGreaterThanOrEqual(24)
|
||||
})
|
||||
|
||||
test('widget titles are h2 under the page h1', async ({ page }) => {
|
||||
await setupHome(page)
|
||||
await page.goto('/app/a11yLab')
|
||||
await expect(page.getByRole('heading', { level: 1, name: 'A11y Lab' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { level: 2 })).toHaveText(['Open Orders', 'Returns'])
|
||||
await expect(page.getByRole('heading', { level: 3 })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('the skip link moves focus to the first widget, and main is focusable', async ({ page }) => {
|
||||
await setupHome(page)
|
||||
await page.goto('/app/a11yLab')
|
||||
await expect(page.locator('[data-qqq-id="widget-grid-item-openOrders"]')).toBeVisible()
|
||||
await expect(page.locator('main#main-content')).toHaveAttribute('tabindex', '-1')
|
||||
await page.locator('[data-qqq-id="skip-to-content"]').focus()
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page.locator('[data-qqq-id="widget-grid-item-openOrders"]')).toBeFocused()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user