Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/hub-ui/src/client/components/dock/DockEntries.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
import type { DevframeDockEntry } from '@devframes/hub'
import type { DocksContext } from '@devframes/hub/client'
import { evaluateWhen } from 'devframe/utils/when'
import { toRefs } from 'vue'
import { computed, toRefs } from 'vue'
import { getGroupMembers, resolveGroupDefaultChild } from '../../state/dock-settings'
import { getCurrentDockId } from '../../state/docks'
import { useSettings } from '../../state/settings-defaults'
import DockEntry from './DockEntry.vue'
import DockGroupButton from './DockGroupButton.vue'
Expand All @@ -23,6 +24,7 @@ const emit = defineEmits<{
}>()

const { selected, isVertical, entries } = toRefs(props)
const currentId = computed(() => getCurrentDockId(props.context))

const settings = useSettings(props.context)

Expand Down Expand Up @@ -79,6 +81,7 @@ function toggleDockEntry(dock: DevframeDockEntry) {
:dock
:is-action="dock.type === 'action'"
:is-selected="isDockActive(dock)"
:current="currentId === dock.id ? 'page' : undefined"
:is-dimmed="dimInactive && selected ? !isDockActive(dock) : false"
:is-vertical="isVertical"
:badge="dock.badge"
Expand Down
7 changes: 7 additions & 0 deletions packages/hub-ui/src/client/components/dock/DockEntry.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ const props = withDefaults(
dock: DevframeDockEntryBase
isAction?: boolean
isSelected?: boolean
/**
* The `aria-current` of the button: `page` for the dock shown now, `true`
* for a group or overflow button that holds it while it is out of sight.
* Unlike `isSelected`, which is also on while a group popover is open.
*/
current?: 'page' | 'true'
isDimmed?: boolean
isVertical?: boolean
badge?: string
Expand Down Expand Up @@ -98,6 +104,7 @@ useEventListener('pointerdown', () => {
<button
ref="button"
:aria-label="dock.title"
:aria-current="current"
:class="[
isVertical ? 'rotate-270' : '',
isDimmed ? 'op50 saturate-0' : '',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { DocksContext } from '@devframes/hub/client'
import { watchDebounced } from '@vueuse/core'
import { computed, h, onBeforeUnmount, ref, useTemplateRef } from 'vue'
import { getGroupMembers, getGroupMembersGrouped, resolveGroupPreferredChild } from '../../state/dock-settings'
import { getCurrentDockId, holdsCurrentDock } from '../../state/docks'
import { setDocksGroupPanel, useDocksGroupPanel } from '../../state/floating-tooltip'
import { useSettings } from '../../state/settings-defaults'
import { accentVarStyle } from '../../utils/accent-color'
Expand Down Expand Up @@ -47,6 +48,9 @@ const isActive = computed(() => {
return !!id && members.value.some(m => m.id === id)
})

// The shown dock is one of this group's members, hidden or not.
const holdsCurrent = computed(() => holdsCurrentDock(props.context, props.group, getCurrentDockId(props.context)))

// A member toggle action that is on lights the button up too, without owning the panel.
const hasActiveTool = computed(() => members.value.some(m => m.type === 'action' && props.context.docks.getStateById(m.id)?.isActive))

Expand Down Expand Up @@ -136,6 +140,7 @@ function onClick() {
:is-vertical="isVertical"
:is-selected="isActive || isPanelVisible || hasActiveTool"
:is-dimmed="dimInactive && selected ? !isActive && !hasActiveTool : false"
:current="holdsCurrent ? 'true' : undefined"
:badge="group.badge"
:badge-variant="group.badgeVariant"
@click="onClick"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { DevframeDockEntriesGrouped, DevframeDockEntry, DevframeViewGroup }
import type { DocksContext } from '@devframes/hub/client'
import { computed } from 'vue'
import { t } from '../../i18n'
import { getCurrentDockId } from '../../state/docks'
import { dockTooltipContent, setFloatingTooltip } from '../../state/floating-tooltip'
import { accentVarStyle } from '../../utils/accent-color'
import { openDockContextMenu } from './DockContextMenu'
Expand All @@ -20,6 +21,8 @@ const emit = defineEmits<{
(e: 'select', entry: DevframeDockEntry): void
}>()

const currentId = computed(() => getCurrentDockId(props.context))

// A row already shows its title, so only a `description` is worth a tooltip.
function showTooltip(event: PointerEvent, member: DevframeDockEntry) {
if (member.description)
Expand Down Expand Up @@ -65,6 +68,7 @@ function openContextMenu(e: MouseEvent, member: DevframeDockEntry) {
<button
v-for="member of items"
:key="member.id"
:aria-current="currentId === member.id ? 'page' : undefined"
class="flex items-center gap-2 w-full px2 py1.5 rounded text-sm text-left transition"
:class="selectedId === member.id || context.docks.getStateById(member.id)?.isActive ? 'text-primary bg-active' : 'op80 hover:op100 hover:bg-active'"
@pointerenter="showTooltip($event, member)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { watchDebounced } from '@vueuse/core'
import { computed, h, ref, useTemplateRef, watch } from 'vue'
import { t } from '../../i18n'
import { deriveSidebarCapacity, docksSplitGroupsWithCapacity, getGroupMembersGrouped } from '../../state/dock-settings'
import { getCurrentDockId } from '../../state/docks'
import { dockTooltipContent, setDocksSidebarOverflowPanel, setFloatingTooltip, useDocksSidebarOverflowPanel } from '../../state/floating-tooltip'
import { useSettings } from '../../state/settings-defaults'
import { accentVarStyle } from '../../utils/accent-color'
Expand All @@ -18,6 +19,8 @@ const props = defineProps<{
selectedId: string | null
}>()

const currentId = computed(() => getCurrentDockId(props.context))

// Vertical rhythm of the rail, in px. Mirrors the Uno classes on the template:
// each button is `w-8 h-8` (32) and children sit in a `gap-0.5` (2) flex column;
// the root has `py1.5` (6+6) padding; dividers are `h-px my0.5` (1 + 2*2).
Expand Down Expand Up @@ -84,6 +87,10 @@ const overflowCount = computed(() => overflowGroups.value.reduce((acc, [, items]
const hasOverflow = computed(() => overflowCount.value > 0)
const overflowBadge = computed(() => (overflowCount.value > 9 ? '9+' : overflowCount.value.toString()))

// The shown member is hidden inside the show-more popover.
const currentInOverflow = computed(() =>
!!currentId.value && overflowGroups.value.some(([, items]) => items.some(m => m.id === currentId.value)))

// The current selection is hidden inside the show-more popover.
const selectedInOverflow = computed(() =>
!!props.selectedId && overflowGroups.value.some(([, items]) => items.some(m => m.id === props.selectedId)))
Expand Down Expand Up @@ -190,6 +197,7 @@ const moreButtonActive = computed(() => selectedInOverflow.value || isOverflowPa
v-for="member of members"
:key="member.id"
:aria-label="member.title"
:aria-current="currentId === member.id ? 'page' : undefined"
Comment thread
Copilot marked this conversation as resolved.
Resolved
class="relative flex items-center justify-center w-8 h-8 rounded-lg transition"
:class="selectedId === member.id ? 'text-primary bg-active' : 'op60 hover:op100 hover:bg-active'"
@pointerenter="showTooltip($event, member)"
Expand All @@ -213,6 +221,7 @@ const moreButtonActive = computed(() => selectedInOverflow.value || isOverflowPa
v-if="hasOverflow"
ref="moreButton"
:aria-label="t('dock.showMore')"
:aria-current="currentInOverflow ? 'true' : undefined"
class="relative flex items-center justify-center w-8 h-8 rounded-lg transition mt-auto flex-none"
:class="moreButtonActive ? 'text-primary bg-active' : 'op60 hover:op100 hover:bg-active'"
@pointerenter="showTooltip($event, t('dock.showMore'))"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { FloatingPopoverProps } from '../../state/floating-tooltip'
import { watchDebounced } from '@vueuse/core'
import { computed, h, onBeforeUnmount, ref, useTemplateRef } from 'vue'
import { t } from '../../i18n'
import { getCurrentDockId, holdsCurrentDock } from '../../state/docks'
import { setDocksOverflowPanel, useDocksOverflowPanel } from '../../state/floating-tooltip'
import DockEntriesWithCategories from './DockEntriesWithCategories.vue'
import DockEntry from './DockEntry.vue'
Expand All @@ -31,6 +32,12 @@ const overflowBadge = computed(() => {
return count.toString()
})

// The shown dock, or the group that holds it, is in the overflow.
const holdsCurrent = computed(() => {
const currentId = getCurrentDockId(props.context)
return props.groups.some(([, items]) => items.some(entry => holdsCurrentDock(props.context, entry, currentId)))
})

const isOverflowPanelVisible = ref(false)
const docksOverflowPanel = useDocksOverflowPanel()

Expand Down Expand Up @@ -110,6 +117,7 @@ onBeforeUnmount(() => {
:is-vertical="isVertical"
:is-selected="false"
:is-dimmed="false"
:current="holdsCurrent ? 'true' : undefined"
@click="toggleOverflowPanel"
/>
</div>
Expand Down
42 changes: 42 additions & 0 deletions packages/hub-ui/src/client/state/docks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { DevframeDockEntry } from '@devframes/hub'
import type { DocksContext } from '@devframes/hub/client'
import { describe, expect, it } from 'vitest'
import { getCurrentDockId, holdsCurrentDock } from './docks'

const group = { type: 'group', id: 'tools', title: 'Tools', icon: 'i' } as DevframeDockEntry
const member = { type: 'iframe', id: 'logs', title: 'Logs', icon: 'i', url: '/', groupId: 'tools' } as DevframeDockEntry
const action = { type: 'action', id: 'reload', title: 'Reload', icon: 'i' } as DevframeDockEntry

function contextWith(selected: DevframeDockEntry | null, options: { clientType?: 'embedded' | 'standalone', open?: boolean, recent?: string[], topLevel?: DevframeDockEntry[] } = {}): DocksContext {
// eslint-disable-next-line slop/no-chained-type-assertions -- this fixture supplies only the context fields the dock helpers read.
return {
clientType: options.clientType ?? 'standalone',
panel: { session: { open: options.open ?? true, recentDockIds: options.recent } },
docks: { selected, entries: [group, member, action], groupedEntries: [['default', options.topLevel ?? [group, action]]] },
} as unknown as DocksContext
}

describe('getCurrentDockId', () => {
it('is the selected view, never an action, and none while an embedded panel is closed', () => {
expect(getCurrentDockId(contextWith(member))).toBe('logs')
expect(getCurrentDockId(contextWith(action))).toBeUndefined()
expect(getCurrentDockId(contextWith(null))).toBeUndefined()
expect(getCurrentDockId(contextWith(member, { clientType: 'embedded', open: false }))).toBeUndefined()
expect(getCurrentDockId(contextWith(member, { clientType: 'embedded', open: true }))).toBe('logs')
})
})

describe('holdsCurrentDock', () => {
it('holds the current dock itself and the group of a current member', () => {
const context = contextWith(member)
expect(holdsCurrentDock(context, member, 'logs')).toBe(true)
expect(holdsCurrentDock(context, group, 'logs')).toBe(true)
expect(holdsCurrentDock(context, action, 'logs')).toBe(false)
expect(holdsCurrentDock(context, group, undefined)).toBe(false)
})

it('leaves the group out when the member has its own button on the bar', () => {
expect(holdsCurrentDock(contextWith(member, { recent: ['logs'] }), group, 'logs')).toBe(false)
expect(holdsCurrentDock(contextWith(member, { topLevel: [group, member] }), group, 'logs')).toBe(false)
})
})
40 changes: 39 additions & 1 deletion packages/hub-ui/src/client/state/docks.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,48 @@
import type { DevframeDockEntry } from '@devframes/hub'
import type { DevframeRpcClient, DockEntryState, DockEntryStateEvents, DockPanelStorage, DockSessionStorage } from '@devframes/hub/client'
import type { DevframeRpcClient, DockEntryState, DockEntryStateEvents, DockPanelStorage, DocksContext, DockSessionStorage } from '@devframes/hub/client'
import type { SharedState } from 'devframe/utils/shared-state'
import type { Ref, ShallowRef } from 'vue'
import { createEventEmitter } from 'devframe/utils/events'
import { markRaw, reactive, shallowRef, watch } from 'vue'

/**
* The id of the dock whose view is shown now, which the dock marks
* `aria-current`: none for an action, and none while the panel of an
* embedded dock is closed.
*/
export function getCurrentDockId(context: DocksContext): string | undefined {
const entry = context.docks.selected
if (!entry || entry.type === 'action')
return undefined
if (context.clientType === 'embedded' && !context.panel.session.open)
return undefined
Comment thread
erkamyaman marked this conversation as resolved.
Resolved
return entry.id
}

/**
* Whether `entry` is the current dock, or a group whose member is the
* current dock and has no button of its own on the dock bar (a pinned or
* recent button), so the group button stands in for it. `currentId` is
* {@link getCurrentDockId}.
*/
export function holdsCurrentDock(context: DocksContext, entry: DevframeDockEntry, currentId: string | undefined): boolean {
if (!currentId)
return false
if (entry.id === currentId)
return true
return entry.type === 'group'
&& context.docks.entries.find(e => e.id === currentId)?.groupId === entry.id
&& !hasOwnDockButton(context, currentId)
}

/** Whether the dock has its own button on the dock bar: pinned out of its group, or raised as a recent dock. */
function hasOwnDockButton(context: DocksContext, id: string): boolean {
const session = context.panel.session
const recentIds = session.recentDockIds ?? (session.recentDockId ? [session.recentDockId] : [])
return recentIds.includes(id)
|| context.docks.groupedEntries.some(([, items]) => items.some(entry => entry.id === id))
Comment on lines +41 to +43
}

export function DEFAULT_DOCK_PANEL_STORE(): DockPanelStorage {
return {
mode: 'float',
Expand Down
Loading