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 @@ -545,7 +545,8 @@ export default {
As a simple solution - render it in the content to keep correct position.
-->
<Teleport v-if="ncContentSelector && !open && !noToggle" :selector="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 @@ -565,105 +566,111 @@ export default {
'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="hasFigure && !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 || $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': !$slots['secondary-actions'],
}"
class="app-sidebar-header__desc">
<!-- favourite icon -->
<div v-if="canStar || $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" />
<Star v-else-if="isStarred" :size="20" />
<StarOutline 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="hasFigure"
: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>
<ArrowRight :size="20" />
</template>
</NcButton>
</form>
</template>
<!-- header main menu -->
<NcActions v-if="$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 || $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': !$slots['secondary-actions'],
}"
class="app-sidebar-header__desc">
<!-- favourite icon -->
<div v-if="canStar || $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" />
<Star v-else-if="isStarred" :size="20" />
<StarOutline 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="name"
:linkify="linkifyName"
:title="title"
:tabindex="nameEditable ? 0 : -1"
@click.self.native="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>
<ArrowRight :size="20" />
</template>
</NcButton>
</form>
</template>
<!-- header main menu -->
<NcActions v-if="$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="name"
tabindex="-1" />

<NcButton ref="closeButton"
:aria-label="closeTranslated"
Expand Down Expand Up @@ -704,11 +711,11 @@ import { Portal as Teleport } from '@linusborg/vue-simple-portal'

import NcAppSidebarTabs from './NcAppSidebarTabs.vue'
import NcActions from '../NcActions/index.js'
import NcAppSidebarHeader from '../NcAppSidebarHeader/index.ts'
import NcButton from '../NcButton/index.js'
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.js'
import { useIsSmallMobile } from '../../composables/useIsMobile/index.js'
import GenRandomId from '../../utils/GenRandomId.js'
import { getTrapStack } from '../../utils/focusTrap.ts'
Expand All @@ -722,13 +729,15 @@ import StarOutline from 'vue-material-design-icons/StarOutline.vue'

import { vOnClickOutside as ClickOutside } from '@vueuse/components'
import { createFocusTrap } from 'focus-trap'
import Vue, { provide, ref } from 'vue'

export default {
name: 'NcAppSidebar',

components: {
Teleport,
NcActions,
NcAppSidebarHeader,
NcAppSidebarTabs,
ArrowRight,
IconDockRight,
Expand All @@ -742,7 +751,6 @@ export default {

directives: {
focus: Focus,
linkify: Linkify,
ClickOutside,
},

Expand Down Expand Up @@ -923,9 +931,13 @@ export default {
],

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

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

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

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

/**
Expand Down Expand Up @@ -1305,6 +1326,9 @@ $top-buttons-spacing: $app-navigation-padding; // align with app navigation
}

.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 @@ -1340,11 +1364,11 @@ $top-buttons-spacing: $app-navigation-padding; // align with app navigation
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 @@ -1356,7 +1380,7 @@ $top-buttons-spacing: $app-navigation-padding; // align with app navigation
}
.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 @@ -1369,14 +1393,14 @@ $top-buttons-spacing: $app-navigation-padding; // align with app navigation
.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 @@ -1514,6 +1538,17 @@ $top-buttons-spacing: $app-navigation-padding; // align with app navigation
}
}

// 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
44 changes: 44 additions & 0 deletions src/components/NcAppSidebarHeader/NcAppSidebarHeader.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<script setup>
import { inject } from 'vue'
import vLinkify from '../../directives/Linkify/index.js'

defineProps({
/**
* The name used in NcAppSidebar header.
*/
name: {
type: String,
required: true,
},

/**
* Title to display for the name.
*/
title: {

Check warning on line 22 in src/components/NcAppSidebarHeader/NcAppSidebarHeader.vue

View workflow job for this annotation

GitHub Actions / NPM lint

Prop 'title' requires default value to be set
type: String,
},

/**
* Linkify the name.
*/
linkify: {
type: Boolean,
},
})

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

<template>
<h2 ref="headerRef"
v-linkify="{ text: name, linkify }"
tabindex="-1"
:title="title">
{{ name }}
</h2>
</template>
Loading
Loading