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
243 changes: 139 additions & 104 deletions src/components/NcAppSidebar/NcAppSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,8 @@
As a simple solution - render it in the content to keep correct position.
-->
<Teleport v-if="ncContentSelector && !open && !noToggle" :to="ncContentSelector">
<NcButton :aria-label="t('Open sidebar')"
<NcButton ref="toggle"
:aria-label="t('Open sidebar')"
class="app-sidebar__toggle"
:class="toggleClasses"
variant="tertiary"
Expand All @@ -570,105 +571,111 @@
'app-sidebar-header--compact': compact,
}"
class="app-sidebar-header">
<!-- container for figure and description, allows easy switching to compact mode -->
<div class="app-sidebar-header__info">
<!-- sidebar header illustration/figure -->
<div v-if="(isSlotPopulated($slots.header?.()) || background) && !empty"
:class="{
'app-sidebar-header__figure--with-action': hasFigureClickListener
}"
class="app-sidebar-header__figure"
:style="{
backgroundImage: `url(${background})`
}"
tabindex="0"
@click="onFigureClick"
@keydown.enter="onFigureClick">
<slot class="app-sidebar-header__background" name="header" />
</div>

<!-- sidebar details -->
<div v-if="!empty"
:class="{
'app-sidebar-header__desc--with-tertiary-action': canStar || isSlotPopulated($slots['tertiary-actions']?.()),
'app-sidebar-header__desc--editable': nameEditable && !subname,
'app-sidebar-header__desc--with-subname--editable': nameEditable && subname,
'app-sidebar-header__desc--without-actions': !isSlotPopulated($slots['secondary-actions']?.()),
}"
class="app-sidebar-header__desc">
<!-- favourite icon -->
<div v-if="canStar || isSlotPopulated($slots['tertiary-actions']?.())" class="app-sidebar-header__tertiary-actions">
<slot name="tertiary-actions">
<NcButton v-if="canStar"
:aria-label="favoriteTranslated"
:pressed="isStarred"
class="app-sidebar-header__star"
variant="secondary"
@click.prevent="toggleStarred">
<template #icon>
<NcLoadingIcon v-if="starLoading" />
<IconStar v-else-if="isStarred" :size="20" />
<IconStarOutline v-else :size="20" />
</template>
</NcButton>
</slot>
<!-- @slot Alternative to the default header info: use for bare NcAppSidebar with tabs.
NcAppSidebarHeader would be required to use for accessibility reasons.
This will be overridden by `empty` prop.
-->
<slot v-if="!empty" name="info">
<!-- container for figure and description, allows easy switching to compact mode -->
<div class="app-sidebar-header__info">
<!-- sidebar header illustration/figure -->
<div v-if="(isSlotPopulated($slots.header?.()) || background)"
:class="{
'app-sidebar-header__figure--with-action': hasFigureClickListener
}"
class="app-sidebar-header__figure"
:style="{
backgroundImage: `url(${background})`
}"
tabindex="0"
@click="onFigureClick"
@keydown.enter="onFigureClick">
<slot class="app-sidebar-header__background" name="header" />
</div>

<!-- name -->
<div class="app-sidebar-header__name-container">
<div class="app-sidebar-header__mainname-container">
<!-- main name -->
<h2 v-show="!nameEditable"
:id="`app-sidebar-vue-${uid}__header`"
ref="header"
v-linkify="{text: name, linkify: linkifyName}"
:aria-label="title"
:title="title"
class="app-sidebar-header__mainname"
:tabindex="nameEditable ? 0 : -1"
@click.self="editName">
{{ name }}
</h2>
<template v-if="nameEditable">
<form v-click-outside="() => onSubmitName()"
class="app-sidebar-header__mainname-form"
@submit.prevent="onSubmitName">
<input ref="nameInput"
v-focus
class="app-sidebar-header__mainname-input"
type="text"
:placeholder="namePlaceholder"
:value="name"
@keydown.esc.stop="onDismissEditing"
@input="onNameInput">
<NcButton :aria-label="changeNameTranslated"
type="submit"
variant="tertiary-no-background">
<template #icon>
<IconArrowRight :size="20" />
</template>
</NcButton>
</form>
</template>
<!-- header main menu -->
<NcActions v-if="isSlotPopulated($slots['secondary-actions']?.())"
class="app-sidebar-header__menu"
:force-menu="forceMenu">
<slot name="secondary-actions" />
</NcActions>
</div>
<!-- secondary name -->
<p v-if="subname.trim() !== '' || $slots['subname']"
:title="subtitle || undefined"
class="app-sidebar-header__subname">
<!-- @slot Alternative to the `subname` prop can be used for more complex conent. It will be rendered within a `p` tag. -->
<slot name="subname">
{{ subname }}
<!-- sidebar details -->
<div :class="{
'app-sidebar-header__desc--with-tertiary-action': canStar || isSlotPopulated($slots['tertiary-actions']?.()),
'app-sidebar-header__desc--editable': nameEditable && !subname,
'app-sidebar-header__desc--with-subname--editable': nameEditable && subname,
'app-sidebar-header__desc--without-actions': !isSlotPopulated($slots['secondary-actions']?.()),
}"
class="app-sidebar-header__desc">
<!-- favourite icon -->
<div v-if="canStar || isSlotPopulated($slots['tertiary-actions']?.())" class="app-sidebar-header__tertiary-actions">
<slot name="tertiary-actions">
<NcButton v-if="canStar"
:aria-label="favoriteTranslated"
:pressed="isStarred"
class="app-sidebar-header__star"
variant="secondary"
@click.prevent="toggleStarred">
<template #icon>
<NcLoadingIcon v-if="starLoading" />
<IconStar v-else-if="isStarred" :size="20" />
<IconStarOutline v-else :size="20" />
</template>
</NcButton>
</slot>
</p>
</div>

<!-- name -->
<div class="app-sidebar-header__name-container">
<div class="app-sidebar-header__mainname-container">
<!-- main name -->
<NcAppSidebarHeader v-show="!nameEditable"
class="app-sidebar-header__mainname"
:name
:linkify="linkifyName"
:title
:tabindex="nameEditable ? 0 : -1"
@click.self="editName" />
<template v-if="nameEditable">
<form v-click-outside="() => onSubmitName()"
class="app-sidebar-header__mainname-form"
@submit.prevent="onSubmitName">
<input ref="nameInput"
v-focus
class="app-sidebar-header__mainname-input"
type="text"
:placeholder="namePlaceholder"
:value="name"
@keydown.esc.stop="onDismissEditing"
@input="onNameInput">
<NcButton :aria-label="changeNameTranslated"
type="submit"
variant="tertiary-no-background">
<template #icon>
<IconArrowRight :size="20" />
</template>
</NcButton>
</form>
</template>
<!-- header main menu -->
<NcActions v-if="isSlotPopulated($slots['secondary-actions']?.())"
class="app-sidebar-header__menu"
:force-menu="forceMenu">
<slot name="secondary-actions" />
</NcActions>
</div>
<!-- secondary name -->
<p v-if="subname.trim() !== '' || $slots['subname']"
:title="subtitle || undefined"
class="app-sidebar-header__subname">
<!-- @slot Alternative to the `subname` prop can be used for more complex conent. It will be rendered within a `p` tag. -->
<slot name="subname">
{{ subname }}
</slot>
</p>
</div>
</div>
</div>
</div>
</slot>
<!-- a11y fallback for empty content -->
<NcAppSidebarHeader v-else
class="app-sidebar-header__mainname--hidden"
:name
tabindex="-1" />

<NcButton ref="closeButton"
:aria-label="closeTranslated"
Expand Down Expand Up @@ -706,13 +713,14 @@
<script>
import NcAppSidebarTabs from './NcAppSidebarTabs.vue'
import NcActions from '../NcActions/index.js'
import NcAppSidebarHeader from '../NcAppSidebarHeader/index.ts'
import NcButton from '../NcButton/index.ts'
import NcEmptyContent from '../NcEmptyContent/index.js'
import NcLoadingIcon from '../NcLoadingIcon/index.js'
import Focus from '../../directives/Focus/index.js'
import Linkify from '../../directives/Linkify/index.ts'
import { vOnClickOutside as ClickOutside } from '@vueuse/components'
import { createFocusTrap } from 'focus-trap'
import { provide, ref, warn } from 'vue'
import { useIsSmallMobile } from '../../composables/useIsMobile/index.js'
import { createElementId } from '../../utils/createElementId.ts'
import { getTrapStack } from '../../utils/focusTrap.ts'
Expand All @@ -730,6 +738,7 @@

components: {
NcActions,
NcAppSidebarHeader,
NcAppSidebarTabs,
NcButton,
NcLoadingIcon,
Expand All @@ -743,7 +752,6 @@

directives: {
Focus,
Linkify,
ClickOutside,
},

Expand Down Expand Up @@ -922,9 +930,13 @@
],

setup() {
const headerRef = ref(null)
provide('NcAppSidebar:header:ref', headerRef)

return {
uid: createElementId(),
isMobile: useIsSmallMobile(),
headerRef,
}
},

Expand Down Expand Up @@ -1099,7 +1111,7 @@
* @type {Event}
*/
// eslint-disable-next-line vue/require-explicit-emits
this.$emit('figure-click', e)

Check warning on line 1114 in src/components/NcAppSidebar/NcAppSidebar.vue

View workflow job for this annotation

GitHub Actions / NPM lint

Custom event name 'figure-click' must be camelCase
},

/**
Expand Down Expand Up @@ -1135,7 +1147,16 @@
* @public
*/
focus() {
(this.$refs.header ?? this.$refs.toggle)?.focus()
if (!this.open && !this.noToggle) {
this.$refs.toggle.$el.focus()
return
}

try {
this.headerRef.focus()
} catch {
warn('NcAppSidebar should have focusable header for accessibility reasons. Use NcAppSidebarHeader component.')
}
},

/**
Expand Down Expand Up @@ -1190,7 +1211,7 @@
*
* @type {Event}
*/
this.$emit('submit-name', event)

Check warning on line 1214 in src/components/NcAppSidebar/NcAppSidebar.vue

View workflow job for this annotation

GitHub Actions / NPM lint

Custom event name 'submit-name' must be camelCase
},
onDismissEditing() {
// Disable editing
Expand All @@ -1200,7 +1221,7 @@
*
* @type {Event}
*/
this.$emit('dismiss-editing')

Check warning on line 1224 in src/components/NcAppSidebar/NcAppSidebar.vue

View workflow job for this annotation

GitHub Actions / NPM lint

Custom event name 'dismiss-editing' must be camelCase
},
onUpdateActive(activeTab) {
/**
Expand Down Expand Up @@ -1287,6 +1308,9 @@
}

.app-sidebar-header {
// Variable for custom content to be aware of space taken by close button (from top-right corner)
--app-sidebar-close-button-offset: calc(var(--default-clickable-area) + #{$top-buttons-spacing});

> .app-sidebar__close {
position: absolute;
z-index: 100;
Expand Down Expand Up @@ -1321,11 +1345,11 @@
padding-inline-start: 0;
flex: 1 1 auto;
min-width: 0;
padding-inline-end: calc(2 * var(--default-clickable-area) + $top-buttons-spacing);
padding-inline-end: calc(var(--default-clickable-area) + var(--app-sidebar-close-button-offset));
padding-top: var(--app-sidebar-padding);

&.app-sidebar-header__desc--without-actions {
padding-inline-end: calc(var(--default-clickable-area) + $top-buttons-spacing);
padding-inline-end: var(--app-sidebar-close-button-offset);
}

.app-sidebar-header__tertiary-actions {
Expand All @@ -1337,7 +1361,7 @@
}
.app-sidebar-header__menu {
top: $top-buttons-spacing;
inset-inline-end: calc(var(--default-clickable-area) + $top-buttons-spacing); // left of the close button
inset-inline-end: var(--app-sidebar-close-button-offset); // left of the close button
position: absolute;
}
}
Expand All @@ -1350,14 +1374,14 @@
.app-sidebar-header__menu {
position: absolute;
top: $top-buttons-spacing;
inset-inline-end: calc($top-buttons-spacing + var(--default-clickable-area));
inset-inline-end: var(--app-sidebar-close-button-offset);
}
// increase the padding to not overlap the menu
.app-sidebar-header__desc {
padding-inline-end: calc(var(--default-clickable-area) * 2 + $top-buttons-spacing);
padding-inline-end: calc(var(--default-clickable-area) + var(--app-sidebar-close-button-offset));

&.app-sidebar-header__desc--without-actions {
padding-inline-end: calc(var(--default-clickable-area) + $top-buttons-spacing);
padding-inline-end: var(--app-sidebar-close-button-offset);
}
}
}
Expand Down Expand Up @@ -1494,6 +1518,17 @@
}
}

// Hidden a11y fallback
.app-sidebar-header__mainname--hidden {
position: absolute;
top: 0;
inset-inline-start: 0;
margin: 0;
width: 1px;
height: 1px;
overflow: hidden;
}

// sidebar description slot
&__description {
display: flex;
Expand Down
37 changes: 37 additions & 0 deletions src/components/NcAppSidebarHeader/NcAppSidebarHeader.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<script setup lang="ts">
import { inject } from 'vue'
import vLinkify from '../../directives/Linkify/index.ts'

defineProps<{
/**
* The name used in NcAppSidebar header.
*/
name: string,

/**
* Title to display for the name.
*/
title?: string,

/**
* Linkify the name.
*/
linkify?: boolean,
}>()

const headerRef = inject('NcAppSidebar:header:ref')
</script>

<template>
<h2 ref="headerRef"
v-linkify="{ text: name, linkify }"
tabindex="-1"
:title>
{{ name }}
</h2>
</template>
6 changes: 6 additions & 0 deletions src/components/NcAppSidebarHeader/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

export { default } from './NcAppSidebarHeader.vue'
Loading
Loading