Skip to content

Commit be9fd6f

Browse files
authored
fix(hub-ui): mark the selected dock with aria-current (#475)
1 parent bf41031 commit be9fd6f

8 files changed

Lines changed: 118 additions & 2 deletions

File tree

‎packages/hub-ui/src/client/components/dock/DockEntries.vue‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
import type { DevframeDockEntry } from '@devframes/hub'
33
import type { DocksContext } from '@devframes/hub/client'
44
import { evaluateWhen } from 'devframe/utils/when'
5-
import { toRefs } from 'vue'
5+
import { computed, toRefs } from 'vue'
66
import { getGroupMembers, resolveGroupDefaultChild } from '../../state/dock-settings'
7+
import { getCurrentDockId } from '../../state/docks'
78
import { useSettings } from '../../state/settings-defaults'
89
import DockEntry from './DockEntry.vue'
910
import DockGroupButton from './DockGroupButton.vue'
@@ -23,6 +24,7 @@ const emit = defineEmits<{
2324
}>()
2425
2526
const { selected, isVertical, entries } = toRefs(props)
27+
const currentId = computed(() => getCurrentDockId(props.context))
2628
2729
const settings = useSettings(props.context)
2830
@@ -79,6 +81,7 @@ function toggleDockEntry(dock: DevframeDockEntry) {
7981
:dock
8082
:is-action="dock.type === 'action'"
8183
:is-selected="isDockActive(dock)"
84+
:current="currentId === dock.id ? 'page' : undefined"
8285
:is-dimmed="dimInactive && selected ? !isDockActive(dock) : false"
8386
:is-vertical="isVertical"
8487
:badge="dock.badge"

‎packages/hub-ui/src/client/components/dock/DockEntry.vue‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ const props = withDefaults(
1313
dock: DevframeDockEntryBase
1414
isAction?: boolean
1515
isSelected?: boolean
16+
/**
17+
* The `aria-current` of the button: `page` for the dock shown now, `true`
18+
* for a group or overflow button that holds it while it is out of sight.
19+
* Unlike `isSelected`, which is also on while a group popover is open.
20+
*/
21+
current?: 'page' | 'true'
1622
isDimmed?: boolean
1723
isVertical?: boolean
1824
badge?: string
@@ -98,6 +104,7 @@ useEventListener('pointerdown', () => {
98104
<button
99105
ref="button"
100106
:aria-label="dock.title"
107+
:aria-current="current"
101108
:class="[
102109
isVertical ? 'rotate-270' : '',
103110
isDimmed ? 'op50 saturate-0' : '',

‎packages/hub-ui/src/client/components/dock/DockGroupButton.vue‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { DocksContext } from '@devframes/hub/client'
44
import { watchDebounced } from '@vueuse/core'
55
import { computed, h, onBeforeUnmount, ref, useTemplateRef } from 'vue'
66
import { getGroupMembers, getGroupMembersGrouped, resolveGroupPreferredChild } from '../../state/dock-settings'
7+
import { getCurrentDockId, holdsCurrentDock } from '../../state/docks'
78
import { setDocksGroupPanel, useDocksGroupPanel } from '../../state/floating-tooltip'
89
import { useSettings } from '../../state/settings-defaults'
910
import { accentVarStyle } from '../../utils/accent-color'
@@ -47,6 +48,9 @@ const isActive = computed(() => {
4748
return !!id && members.value.some(m => m.id === id)
4849
})
4950
51+
// The shown dock is one of this group's members, hidden or not.
52+
const holdsCurrent = computed(() => holdsCurrentDock(props.context, props.group, getCurrentDockId(props.context)))
53+
5054
// A member toggle action that is on lights the button up too, without owning the panel.
5155
const hasActiveTool = computed(() => members.value.some(m => m.type === 'action' && props.context.docks.getStateById(m.id)?.isActive))
5256
@@ -136,6 +140,7 @@ function onClick() {
136140
:is-vertical="isVertical"
137141
:is-selected="isActive || isPanelVisible || hasActiveTool"
138142
:is-dimmed="dimInactive && selected ? !isActive && !hasActiveTool : false"
143+
:current="holdsCurrent ? 'true' : undefined"
139144
:badge="group.badge"
140145
:badge-variant="group.badgeVariant"
141146
@click="onClick"

‎packages/hub-ui/src/client/components/dock/DockGroupPopover.vue‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { DevframeDockEntriesGrouped, DevframeDockEntry, DevframeViewGroup }
33
import type { DocksContext } from '@devframes/hub/client'
44
import { computed } from 'vue'
55
import { t } from '../../i18n'
6+
import { getCurrentDockId } from '../../state/docks'
67
import { dockTooltipContent, setFloatingTooltip } from '../../state/floating-tooltip'
78
import { accentVarStyle } from '../../utils/accent-color'
89
import { openDockContextMenu } from './DockContextMenu'
@@ -20,6 +21,8 @@ const emit = defineEmits<{
2021
(e: 'select', entry: DevframeDockEntry): void
2122
}>()
2223
24+
const currentId = computed(() => getCurrentDockId(props.context))
25+
2326
// A row already shows its title, so only a `description` is worth a tooltip.
2427
function showTooltip(event: PointerEvent, member: DevframeDockEntry) {
2528
if (member.description)
@@ -65,6 +68,7 @@ function openContextMenu(e: MouseEvent, member: DevframeDockEntry) {
6568
<button
6669
v-for="member of items"
6770
:key="member.id"
71+
:aria-current="currentId === member.id ? 'page' : undefined"
6872
class="flex items-center gap-2 w-full px2 py1.5 rounded text-sm text-left transition"
6973
:class="selectedId === member.id || context.docks.getStateById(member.id)?.isActive ? 'text-primary bg-active' : 'op80 hover:op100 hover:bg-active'"
7074
@pointerenter="showTooltip($event, member)"

‎packages/hub-ui/src/client/components/dock/DockGroupSidebar.vue‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { watchDebounced } from '@vueuse/core'
55
import { computed, h, ref, useTemplateRef, watch } from 'vue'
66
import { t } from '../../i18n'
77
import { deriveSidebarCapacity, docksSplitGroupsWithCapacity, getGroupMembersGrouped } from '../../state/dock-settings'
8+
import { getCurrentDockId } from '../../state/docks'
89
import { dockTooltipContent, setDocksSidebarOverflowPanel, setFloatingTooltip, useDocksSidebarOverflowPanel } from '../../state/floating-tooltip'
910
import { useSettings } from '../../state/settings-defaults'
1011
import { accentVarStyle } from '../../utils/accent-color'
@@ -18,6 +19,8 @@ const props = defineProps<{
1819
selectedId: string | null
1920
}>()
2021
22+
const currentId = computed(() => getCurrentDockId(props.context))
23+
2124
// Vertical rhythm of the rail, in px. Mirrors the Uno classes on the template:
2225
// each button is `w-8 h-8` (32) and children sit in a `gap-0.5` (2) flex column;
2326
// the root has `py1.5` (6+6) padding; dividers are `h-px my0.5` (1 + 2*2).
@@ -84,6 +87,10 @@ const overflowCount = computed(() => overflowGroups.value.reduce((acc, [, items]
8487
const hasOverflow = computed(() => overflowCount.value > 0)
8588
const overflowBadge = computed(() => (overflowCount.value > 9 ? '9+' : overflowCount.value.toString()))
8689
90+
// The shown member is hidden inside the show-more popover.
91+
const currentInOverflow = computed(() =>
92+
!!currentId.value && overflowGroups.value.some(([, items]) => items.some(m => m.id === currentId.value)))
93+
8794
// The current selection is hidden inside the show-more popover.
8895
const selectedInOverflow = computed(() =>
8996
!!props.selectedId && overflowGroups.value.some(([, items]) => items.some(m => m.id === props.selectedId)))
@@ -190,6 +197,7 @@ const moreButtonActive = computed(() => selectedInOverflow.value || isOverflowPa
190197
v-for="member of members"
191198
:key="member.id"
192199
:aria-label="member.title"
200+
:aria-current="currentId === member.id ? 'page' : undefined"
193201
class="relative flex items-center justify-center w-8 h-8 rounded-lg transition"
194202
:class="selectedId === member.id ? 'text-primary bg-active' : 'op60 hover:op100 hover:bg-active'"
195203
@pointerenter="showTooltip($event, member)"
@@ -213,6 +221,7 @@ const moreButtonActive = computed(() => selectedInOverflow.value || isOverflowPa
213221
v-if="hasOverflow"
214222
ref="moreButton"
215223
:aria-label="t('dock.showMore')"
224+
:aria-current="currentInOverflow ? 'true' : undefined"
216225
class="relative flex items-center justify-center w-8 h-8 rounded-lg transition mt-auto flex-none"
217226
:class="moreButtonActive ? 'text-primary bg-active' : 'op60 hover:op100 hover:bg-active'"
218227
@pointerenter="showTooltip($event, t('dock.showMore'))"

‎packages/hub-ui/src/client/components/dock/DockOverflowButton.vue‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { FloatingPopoverProps } from '../../state/floating-tooltip'
66
import { watchDebounced } from '@vueuse/core'
77
import { computed, h, onBeforeUnmount, ref, useTemplateRef } from 'vue'
88
import { t } from '../../i18n'
9+
import { getCurrentDockId, holdsCurrentDock } from '../../state/docks'
910
import { setDocksOverflowPanel, useDocksOverflowPanel } from '../../state/floating-tooltip'
1011
import DockEntriesWithCategories from './DockEntriesWithCategories.vue'
1112
import DockEntry from './DockEntry.vue'
@@ -31,6 +32,12 @@ const overflowBadge = computed(() => {
3132
return count.toString()
3233
})
3334
35+
// The shown dock, or the group that holds it, is in the overflow.
36+
const holdsCurrent = computed(() => {
37+
const currentId = getCurrentDockId(props.context)
38+
return props.groups.some(([, items]) => items.some(entry => holdsCurrentDock(props.context, entry, currentId)))
39+
})
40+
3441
const isOverflowPanelVisible = ref(false)
3542
const docksOverflowPanel = useDocksOverflowPanel()
3643
@@ -110,6 +117,7 @@ onBeforeUnmount(() => {
110117
:is-vertical="isVertical"
111118
:is-selected="false"
112119
:is-dimmed="false"
120+
:current="holdsCurrent ? 'true' : undefined"
113121
@click="toggleOverflowPanel"
114122
/>
115123
</div>
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { DevframeDockEntry } from '@devframes/hub'
2+
import type { DocksContext } from '@devframes/hub/client'
3+
import { describe, expect, it } from 'vitest'
4+
import { getCurrentDockId, holdsCurrentDock } from './docks'
5+
6+
const group = { type: 'group', id: 'tools', title: 'Tools', icon: 'i' } as DevframeDockEntry
7+
const member = { type: 'iframe', id: 'logs', title: 'Logs', icon: 'i', url: '/', groupId: 'tools' } as DevframeDockEntry
8+
const action = { type: 'action', id: 'reload', title: 'Reload', icon: 'i' } as DevframeDockEntry
9+
10+
function contextWith(selected: DevframeDockEntry | null, options: { clientType?: 'embedded' | 'standalone', open?: boolean, recent?: string[], topLevel?: DevframeDockEntry[] } = {}): DocksContext {
11+
// eslint-disable-next-line slop/no-chained-type-assertions -- this fixture supplies only the context fields the dock helpers read.
12+
return {
13+
clientType: options.clientType ?? 'standalone',
14+
panel: { session: { open: options.open ?? true, recentDockIds: options.recent } },
15+
docks: { selected, entries: [group, member, action], groupedEntries: [['default', options.topLevel ?? [group, action]]] },
16+
} as unknown as DocksContext
17+
}
18+
19+
describe('getCurrentDockId', () => {
20+
it('is the selected view, never an action, and none while an embedded panel is closed', () => {
21+
expect(getCurrentDockId(contextWith(member))).toBe('logs')
22+
expect(getCurrentDockId(contextWith(action))).toBeUndefined()
23+
expect(getCurrentDockId(contextWith(null))).toBeUndefined()
24+
expect(getCurrentDockId(contextWith(member, { clientType: 'embedded', open: false }))).toBeUndefined()
25+
expect(getCurrentDockId(contextWith(member, { clientType: 'embedded', open: true }))).toBe('logs')
26+
})
27+
})
28+
29+
describe('holdsCurrentDock', () => {
30+
it('holds the current dock itself and the group of a current member', () => {
31+
const context = contextWith(member)
32+
expect(holdsCurrentDock(context, member, 'logs')).toBe(true)
33+
expect(holdsCurrentDock(context, group, 'logs')).toBe(true)
34+
expect(holdsCurrentDock(context, action, 'logs')).toBe(false)
35+
expect(holdsCurrentDock(context, group, undefined)).toBe(false)
36+
})
37+
38+
it('leaves the group out when the member has its own button on the bar', () => {
39+
expect(holdsCurrentDock(contextWith(member, { recent: ['logs'] }), group, 'logs')).toBe(false)
40+
expect(holdsCurrentDock(contextWith(member, { topLevel: [group, member] }), group, 'logs')).toBe(false)
41+
})
42+
})

‎packages/hub-ui/src/client/state/docks.ts‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,48 @@
11
import type { DevframeDockEntry } from '@devframes/hub'
2-
import type { DevframeRpcClient, DockEntryState, DockEntryStateEvents, DockPanelStorage, DockSessionStorage } from '@devframes/hub/client'
2+
import type { DevframeRpcClient, DockEntryState, DockEntryStateEvents, DockPanelStorage, DocksContext, DockSessionStorage } from '@devframes/hub/client'
33
import type { SharedState } from 'devframe/utils/shared-state'
44
import type { Ref, ShallowRef } from 'vue'
55
import { createEventEmitter } from 'devframe/utils/events'
66
import { markRaw, reactive, shallowRef, watch } from 'vue'
77

8+
/**
9+
* The id of the dock whose view is shown now, which the dock marks
10+
* `aria-current`: none for an action, and none while the panel of an
11+
* embedded dock is closed.
12+
*/
13+
export function getCurrentDockId(context: DocksContext): string | undefined {
14+
const entry = context.docks.selected
15+
if (!entry || entry.type === 'action')
16+
return undefined
17+
if (context.clientType === 'embedded' && !context.panel.session.open)
18+
return undefined
19+
return entry.id
20+
}
21+
22+
/**
23+
* Whether `entry` is the current dock, or a group whose member is the
24+
* current dock and has no button of its own on the dock bar (a pinned or
25+
* recent button), so the group button stands in for it. `currentId` is
26+
* {@link getCurrentDockId}.
27+
*/
28+
export function holdsCurrentDock(context: DocksContext, entry: DevframeDockEntry, currentId: string | undefined): boolean {
29+
if (!currentId)
30+
return false
31+
if (entry.id === currentId)
32+
return true
33+
return entry.type === 'group'
34+
&& context.docks.entries.find(e => e.id === currentId)?.groupId === entry.id
35+
&& !hasOwnDockButton(context, currentId)
36+
}
37+
38+
/** Whether the dock has its own button on the dock bar: pinned out of its group, or raised as a recent dock. */
39+
function hasOwnDockButton(context: DocksContext, id: string): boolean {
40+
const session = context.panel.session
41+
const recentIds = session.recentDockIds ?? (session.recentDockId ? [session.recentDockId] : [])
42+
return recentIds.includes(id)
43+
|| context.docks.groupedEntries.some(([, items]) => items.some(entry => entry.id === id))
44+
}
45+
846
export function DEFAULT_DOCK_PANEL_STORE(): DockPanelStorage {
947
return {
1048
mode: 'float',

0 commit comments

Comments
 (0)