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

+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([])
})
})
})