fix: accessibility issues for builder's design

This commit is contained in:
Florian Sylvain committed 2026-10-20 05:11:14 +02:00
1 parent 8e344b2500
commit 76e9e1220c
16 files changed
+290 -147

No files matched your search

+4
View File
@@ -173,6 +173,8 @@
--ui-warning: var(--ui-color-warning-700);
--ui-error: var(--ui-color-error-700);
--rc-sand: #e8dfd1;
--rc-sel: #e8dfd1;
--rc-on-sel: #111111;
--rc-on-sand: #111111;
--rc-success-soft: #eaefea;
--rc-info-soft: #ebeef0;
@@ -206,6 +208,8 @@
--ui-warning: var(--ui-color-warning-300);
--ui-error: var(--ui-color-error-300);
--rc-sand: #d4c5b0;
--rc-sel: #222222;
--rc-on-sel: #fafafa;
--rc-on-sand: #111111;
--rc-success-soft: #1f241f;
--rc-info-soft: #202428;
+5 -7
View File
@@ -5,7 +5,7 @@ import { computed } from 'vue'
const props = defineProps<{
kind: 'new' | 'changed' | 'cause' | 'required' | 'unique'
onSand?: boolean
compact?: boolean
}>()
const badges = {
@@ -42,12 +42,10 @@ const badges = {
},
} as const
const OUTLINE_ON_SAND = 'border border-(--rc-on-sand) bg-transparent text-(--rc-on-sand)'
const badge = computed(() => badges[props.kind])
const badgeIcon = computed(() => ('icon' in badge.value ? badge.value.icon : undefined))
const badgeClass = computed(() =>
props.onSand && props.kind !== 'new' ? OUTLINE_ON_SAND : badge.value.class,
const badgeLabel = computed(() =>
props.compact ? badge.value.label.charAt(0).toUpperCase() : badge.value.label,
)
</script>
@@ -56,12 +54,12 @@ const badgeClass = computed(() =>
color="neutral"
variant="soft"
:icon="badgeIcon"
:class="badgeClass"
:class="badge.class"
:ui="{
base: 'h-5 shrink-0 gap-1 px-1.5 py-0 text-xs font-medium whitespace-nowrap',
leadingIcon: 'size-3',
}"
>
{{ badge.label }}
{{ badgeLabel }}
</UBadge>
</template>
+3 -4
View File
@@ -32,7 +32,6 @@ const emit = defineEmits<{
const root = ref<HTMLElement | null>(null)
const tipOpen = ref(false)
const slugMode = computed(() => keyDisplayMode(props.keyMode, props.keyValue))
const hasError = computed(() => Boolean(props.nameError || props.keyError))
watch(
() => props.keyFocusToken,
@@ -47,7 +46,7 @@ watch(
<div
ref="root"
class="flex min-w-0 flex-col gap-3 transition-opacity md:flex-row md:gap-4"
:class="[hasError ? 'md:items-start' : 'md:items-end', disabled ? 'opacity-60' : '']"
:class="[disabled ? 'opacity-60' : '', 'md:items-start']"
>
<div class="md:w-80">
<DirectoryField :label="t('Name')" name="name" required :error="nameError">
@@ -93,7 +92,7 @@ watch(
</div>
</UTooltip>
</div>
<div v-else :class="keyError ? 'md:w-90' : 'md:w-80'">
<div v-else class="md:w-80">
<SlugField
:label="t('Key')"
name="key"
@@ -117,7 +116,7 @@ watch(
/>
</div>
<span class="hidden flex-1 md:block" />
<div v-if="$slots.actions" :class="hasError ? 'md:mt-5.5' : ''">
<div v-if="$slots.actions" class="flex md:mt-6.5 md:h-8 md:items-center">
<slot name="actions" />
</div>
</div>
+71 -71
View File
@@ -118,8 +118,8 @@ function onChoice(value: unknown) {
}
const SWITCH_UI = {
root: 'items-start gap-3',
wrapper: 'ms-0',
root: 'w-full flex-row-reverse items-center justify-between gap-4 border-b border-default py-3',
wrapper: 'ms-0 flex min-w-0 flex-col gap-0.5',
label: 'font-medium text-highlighted',
description: 'mt-0 text-xs/4.5 text-pretty text-muted',
}
@@ -186,74 +186,36 @@ watch(
@update:model-value="emit('type', $event)"
/>
<div class="grid items-start gap-3.5" :class="gridClass">
<div class="flex min-w-0 flex-col gap-3.5">
<AppSwitch
:model-value="field.required"
:label="t('Required')"
:description="t('An entry can\'t be saved with this field empty.')"
:disabled="disabled"
:ui="SWITCH_UI"
@update:model-value="emit('required', Boolean($event))"
/>
<AppSwitch
:model-value="canUnique && field.unique"
:label="t('Unique values')"
:description="uniqueHelp"
:disabled="disabled || !canUnique"
:ui="SWITCH_UI"
@update:model-value="emit('unique', Boolean($event))"
/>
<AppSwitch
v-if="type === 'boolean'"
:model-value="field.defaultOn"
:label="t('On by default')"
:description="defaultOnHelp"
:disabled="disabled"
:ui="SWITCH_UI"
@update:model-value="emit('defaultOn', Boolean($event))"
/>
<DirectoryField
v-else
:label="t('Default value')"
name="default"
:error="errors.default"
:help="defaultHelp"
>
<USelect
v-if="type === 'select'"
:model-value="choiceValue"
:items="choiceItems"
:placeholder="t('None')"
:disabled="disabled || field.unique"
class="w-full"
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm' }"
@update:model-value="onChoice"
/>
<UTextarea
v-else-if="type === 'richtext'"
:model-value="field.defaultValue"
:placeholder="t('No default')"
:disabled="disabled || field.unique"
:rows="3"
class="w-full"
@update:model-value="emit('default', String($event))"
/>
<UInput
v-else
:model-value="field.defaultValue"
:type="type === 'number' ? 'number' : type === 'date' ? 'date' : 'text'"
:placeholder="t('No default')"
:disabled="disabled || field.unique"
:color="errors.default ? 'error' : undefined"
class="w-full"
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm' }"
@update:model-value="emit('default', String($event))"
/>
</DirectoryField>
</div>
<div class="flex min-w-0 flex-col border-t border-default">
<AppSwitch
:model-value="field.required"
:label="t('Required')"
:description="t('An entry can\'t be saved with this field empty.')"
:disabled="disabled"
:ui="SWITCH_UI"
@update:model-value="emit('required', Boolean($event))"
/>
<AppSwitch
:model-value="canUnique && field.unique"
:label="t('Unique values')"
:description="uniqueHelp"
:disabled="disabled || !canUnique"
:ui="SWITCH_UI"
@update:model-value="emit('unique', Boolean($event))"
/>
<AppSwitch
v-if="type === 'boolean'"
:model-value="field.defaultOn"
:label="t('On by default')"
:description="defaultOnHelp"
:disabled="disabled"
:ui="SWITCH_UI"
@update:model-value="emit('defaultOn', Boolean($event))"
/>
</div>
<div v-if="hasLimits" class="flex min-w-0 flex-col gap-3.5">
<div class="grid items-start gap-3.5" :class="gridClass">
<template v-if="hasLimits">
<DirectoryField :label="limitLabels[0]" name="min" :error="errors.min">
<UInput
:model-value="field.min"
@@ -291,8 +253,46 @@ watch(
</template>
</UInput>
</DirectoryField>
</div>
<div v-else class="flex min-w-0 flex-col gap-1.5">
</template>
<DirectoryField
v-if="type !== 'boolean'"
:label="t('Default value')"
name="default"
:error="errors.default"
:help="defaultHelp"
>
<USelect
v-if="type === 'select'"
:model-value="choiceValue"
:items="choiceItems"
:placeholder="t('None')"
:disabled="disabled || field.unique"
class="w-full"
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm' }"
@update:model-value="onChoice"
/>
<UTextarea
v-else-if="type === 'richtext'"
:model-value="field.defaultValue"
:placeholder="t('No default')"
:disabled="disabled || field.unique"
:rows="3"
class="w-full"
@update:model-value="emit('default', String($event))"
/>
<UInput
v-else
:model-value="field.defaultValue"
:type="type === 'number' ? 'number' : type === 'date' ? 'date' : 'text'"
:placeholder="t('No default')"
:disabled="disabled || field.unique"
:color="errors.default ? 'error' : undefined"
class="w-full"
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm' }"
@update:model-value="emit('default', String($event))"
/>
</DirectoryField>
<div v-if="!hasLimits" class="flex min-w-0 flex-col gap-1.5">
<span class="font-medium text-highlighted">{{ t('Limits') }}</span>
<span class="text-xs/4.5 text-muted">{{ noLimitsText }}</span>
</div>
+1
View File
@@ -141,6 +141,7 @@ function onFocusin(event: FocusEvent) {
@down="onMove(field.uid, 1)"
@remove="emit('remove', field.uid)"
@grip-keydown="reorder.onKeydown($event, index)"
@grip-pointerdown="reorder.onPointerdown($event, index)"
/>
</ul>
<div
+40 -22
View File
@@ -27,6 +27,7 @@ defineEmits<{
down: []
remove: []
'grip-keydown': [event: KeyboardEvent]
'grip-pointerdown': [event: PointerEvent]
}>()
const name = computed(() => props.field.label.trim() || t('Untitled field'))
@@ -35,7 +36,7 @@ const key = computed(() => props.field.key || '–')
const rowClass = computed(() => [
props.invalid ? 'border-error' : props.grabbed ? 'border-primary' : 'border-default',
props.grabbed ? 'shadow-(--shadow-popover) outline-2 -outline-offset-1 outline-primary' : '',
props.selected ? 'bg-(--rc-sand) text-(--rc-on-sand)' : 'bg-default text-highlighted',
props.selected ? 'bg-(--rc-sel) text-(--rc-on-sel)' : 'bg-default text-highlighted',
props.disabled ? 'opacity-60' : '',
])
</script>
@@ -56,9 +57,10 @@ const rowClass = computed(() => [
:aria-pressed="grabbed"
:disabled="disabled"
:tabindex="tabbable ? 0 : -1"
class="flex h-8 w-6 shrink-0 items-center justify-center focus-visible:outline-2 focus-visible:outline-primary max-md:h-11 max-md:w-9"
:class="selected ? 'text-(--rc-on-sand)' : 'text-muted'"
class="flex h-8 w-6 shrink-0 cursor-grab touch-none items-center justify-center focus-visible:outline-2 focus-visible:outline-primary max-md:h-11 max-md:w-9"
:class="selected ? 'text-(--rc-on-sel)' : 'text-muted'"
@keydown="$emit('grip-keydown', $event)"
@pointerdown="$emit('grip-pointerdown', $event)"
@click.stop
>
<UIcon name="i-lucide-grip-vertical" class="size-4" />
@@ -81,27 +83,43 @@ const rowClass = computed(() => [
</span>
<span class="flex min-w-0 flex-1 flex-col">
<span class="truncate font-medium" data-testid="field-row-label">{{ name }}</span>
<span
class="truncate font-mono text-xs"
:class="selected ? 'text-(--rc-on-sand)' : 'text-muted'"
data-testid="field-row-key"
>{{ key }}</span
>
<span class="flex min-w-0 items-center gap-1.5">
<span
class="min-w-0 truncate font-mono text-xs"
:class="selected ? 'text-(--rc-on-sel)' : 'text-muted'"
data-testid="field-row-key"
>{{ key }}</span
>
<span class="flex shrink-0 items-center gap-1.5" data-testid="field-row-badges">
<BuilderBadge v-if="marks.isNew" kind="new" />
<BuilderBadge v-if="marks.changed" kind="changed" />
<BuilderBadge v-if="marks.cause && !selected" kind="cause" />
<span
v-if="field.required"
class="flex shrink-0"
:title="t('Required')"
:aria-label="t('Required')"
>
<BuilderBadge kind="required" compact />
</span>
<span
v-if="field.unique"
class="flex shrink-0"
:title="t('Unique')"
:aria-label="t('Unique')"
>
<BuilderBadge kind="unique" compact />
</span>
</span>
</span>
</span>
</button>
<span class="flex shrink-0 items-center gap-1" data-testid="field-row-badges">
<BuilderBadge v-if="marks.isNew" kind="new" />
<BuilderBadge v-if="marks.changed" kind="changed" />
<BuilderBadge v-if="marks.cause" kind="cause" />
<BuilderBadge v-if="field.required" kind="required" :on-sand="selected" />
<BuilderBadge v-if="field.unique" kind="unique" :on-sand="selected" />
<UIcon
v-if="invalid"
name="i-lucide-circle-alert"
data-testid="field-row-alert"
class="size-4 text-error"
/>
</span>
<UIcon
v-if="invalid && !selected"
name="i-lucide-circle-alert"
data-testid="field-row-alert"
class="mx-1 size-4 shrink-0 text-error"
/>
<FieldActions
v-if="selected && showActions"
:first="index === 0"
@@ -95,7 +95,7 @@ describe('FieldInspector', () => {
)
const alerts = [...document.body.querySelectorAll('[role="alert"]')].map(text)
expect(alerts).toEqual(['Give the field a label.', 'Bad key.', 'Outside.', 'Order.'])
expect(alerts).toEqual(['Give the field a label.', 'Bad key.', 'Order.', 'Outside.'])
})
it('lays the controls out in one column when compact', async () => {
@@ -138,4 +138,72 @@ describe('FieldList', () => {
expect(document.body.querySelector('[data-action]')).toBeNull()
})
describe('dragging a grip', () => {
function layout() {
;[...document.body.querySelectorAll('li[data-uid]')].forEach((row, index) => {
row.getBoundingClientRect = () => ({ bottom: (index + 1) * 60 }) as DOMRect
})
}
function pointer(target: EventTarget, type: string, clientY = 0, button = 0) {
const event = new MouseEvent(type, { bubbles: true, cancelable: true, clientY, button })
Object.defineProperty(event, 'pointerType', { value: 'mouse' })
target.dispatchEvent(event)
}
it('drops the row where the pointer is released and emits the order once', async () => {
await mountInApp(FieldList, props())
layout()
pointer(grip('Title'), 'pointerdown', 10)
await flushPromises()
pointer(window, 'pointermove', 100)
await flushPromises()
expect(labels()).toEqual(['Starts on', 'Title', 'Ends on'])
layout()
pointer(window, 'pointermove', 170)
await flushPromises()
pointer(window, 'pointerup', 170)
await flushPromises()
expect(emitted('reorder')).toEqual([[[2, 3, 1]]])
})
it('emits nothing when it is released where it started', async () => {
await mountInApp(FieldList, props())
layout()
pointer(grip('Title'), 'pointerdown', 10)
pointer(window, 'pointerup', 10)
await flushPromises()
expect(emitted('reorder')).toEqual([])
})
it('ignores a secondary button', async () => {
await mountInApp(FieldList, props())
layout()
pointer(grip('Title'), 'pointerdown', 10, 2)
pointer(window, 'pointermove', 100)
await flushPromises()
expect(labels()).toEqual(['Title', 'Starts on', 'Ends on'])
})
it('puts the rows back when the drag is cancelled', async () => {
await mountInApp(FieldList, props())
layout()
pointer(grip('Title'), 'pointerdown', 10)
pointer(window, 'pointermove', 100)
await flushPromises()
pointer(window, 'pointercancel')
await flushPromises()
expect(labels()).toEqual(['Title', 'Starts on', 'Ends on'])
expect(emitted('reorder')).toEqual([])
})
})
})
+54 -1
View File
@@ -98,6 +98,59 @@ export function useKeyboardReorder<T>(options: KeyboardReorderOptions<T>) {
focusHandle(index)
}
let settling = false
function rowIndexAt(y: number) {
const rows = Array.from(options.root()?.children ?? [])
if (!rows.length) {
return null
}
const found = rows.findIndex((row) => y < row.getBoundingClientRect().bottom)
return found === -1 ? rows.length - 1 : found
}
async function onPointermove(event: PointerEvent) {
const current = grabbed.value
const target = rowIndexAt(event.clientY)
if (settling || current === null || target === null || target === current) {
return
}
settling = true
move(target - current)
await nextTick()
settling = false
}
function stopPointer() {
settling = false
document.body.style.userSelect = ''
window.removeEventListener('pointermove', onPointermove)
window.removeEventListener('pointerup', onPointerup)
window.removeEventListener('pointercancel', onPointercancel)
}
function onPointerup() {
stopPointer()
drop()
}
function onPointercancel() {
stopPointer()
cancel()
}
function onPointerdown(event: PointerEvent, index: number) {
if (grabbed.value !== null || (event.pointerType === 'mouse' && event.button !== 0)) {
return
}
event.preventDefault()
grab(index)
document.body.style.userSelect = 'none'
window.addEventListener('pointermove', onPointermove)
window.addEventListener('pointerup', onPointerup)
window.addEventListener('pointercancel', onPointercancel)
}
function onKeydown(event: KeyboardEvent, index: number) {
if (grabbed.value === null) {
if (event.key === ' ' || event.key === 'Enter') {
@@ -121,5 +174,5 @@ export function useKeyboardReorder<T>(options: KeyboardReorderOptions<T>) {
}
}
return { shown, grabbed, active, announcement, onKeydown }
return { shown, grabbed, active, announcement, onKeydown, onPointerdown }
}
@@ -318,13 +318,15 @@ describe('Field errors (design state 5)', () => {
expect(chip()).toBe('3 problems to fix')
expect(saveButton()!.disabled).toBe(true)
expect(all('[data-testid="field-row-alert"]')).toHaveLength(3)
expect(all('[data-testid="field-row-alert"]')).toHaveLength(2)
expect(text(inspector())).toContain("Choices can't be blank.")
expect(text(inspector())).toContain('Two choices are the same.')
await selectRow('Capacity')
expect(text(inspector())).toContain("Lowest value can't be above the highest value.")
await selectRow('Untitled field')
expect(text(inspector())).toContain('Give the field a label.')
await selectRow('Title')
expect(all('[data-testid="field-row-alert"]')).toHaveLength(3)
})
it('shows a single key error for a new field and none for a saved field', async () => {
@@ -650,6 +650,7 @@ describe('Refused (design state 15 and group 3, state 3)', () => {
await click(button('Apply changes'))
await click(button('Back to editing'))
await vi.waitFor(() => expect(dialog()).toBeNull())
await selectRow('Ends on')
expect(badgesOf('Capacity')).toContain('Possible cause')
answer = (body) => updatedType('events', body)
@@ -187,6 +187,7 @@ describe('When errors are shown', () => {
await typeInto(el<HTMLInputElement>('input[name="label"]'), 'x')
await typeInto(el<HTMLInputElement>('input[name="label"]'), '')
await click(rows()[0]!.querySelector('[data-field-row]'))
const invalid = rows().filter((row) => row.querySelector('[data-testid="field-row-alert"]'))
expect(invalid).toHaveLength(1)
expect(invalid[0]!.className).toContain('border-error')
@@ -81,12 +81,10 @@ describe('Field list rows', () => {
await addField('Second')
const first = rows()[0]!
expect(text(first.querySelector('[data-testid="field-row-badges"]'))).toBe(
'New Required Unique',
)
expect(text(first.querySelector('[data-testid="field-row-badges"]'))).toBe('New R U')
expect(first.querySelector('[data-action]')).toBeNull()
const selected = rows()[1]!
expect(selected.className).toContain('bg-(--rc-sand)')
expect(selected.className).toContain('bg-(--rc-sel)')
expect(
[...selected.querySelectorAll('[data-action]')].map((node) =>
node.getAttribute('aria-label'),