Skip to content

Commit

Permalink
feat(NcAppNavigation): Provide consistent in-app search
Browse files Browse the repository at this point in the history
`NcAppNaviation` now provides an optional in-app search when `show-search` is set.
This allows apps which have in app filtering / search to use a consistent layout.

There is also an actions slot to provide some inline actions like filters.

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
  • Loading branch information
susnux committed Jul 19, 2024
1 parent d7b23c5 commit b24b457
Show file tree
Hide file tree
Showing 4 changed files with 286 additions and 3 deletions.
3 changes: 3 additions & 0 deletions l10n/messages.pot
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,9 @@ msgstr ""
msgid "Search for time zone"
msgstr ""

msgid "Search in app…"
msgstr ""

msgid "Search results"
msgstr ""

Expand Down
162 changes: 159 additions & 3 deletions src/components/NcAppNavigation/NcAppNavigation.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,117 @@ emit('toggle-navigation', {
})
```

#### With in-app search

```vue
<template>
<div class="styleguide-wrapper">
<NcContent app-name="styleguide-app-navigation" class="content-styleguidist">
<NcAppNavigation show-search :search.sync="searchQuery">
<template #search-actions>
<NcActions aria-label="Filters">
<template #icon>
<IconFilter :size="20" />
</template>
<NcActionButton>
<template #icon>
<IconAccount :size="20" />
</template>
Filter by name
</NcActionButton>
<NcActionButton>
<template #icon>
<IconCalendarAccount :size="20" />
</template>
Filter by year
</NcActionButton>
</NcActions>
<NcButton aria-label="Search globally" type="tertiary">
<template #icon>
<IconSearchGlobal :size="20" />
</template>
</NcButton>
</template>
<template #list>
<NcAppNavigationItem name="First navigation entry">
<template #icon>
<IconStar :size="20" />
</template>
</NcAppNavigationItem>
<NcAppNavigationItem name="Second navigation entry">
<template #icon>
<IconStar :size="20" />
</template>
</NcAppNavigationItem>
</template>
</NcAppNavigation>
<NcAppContent>
<ul class="fake-content">
<li>Search query: {{ searchQuery }}</li>
<li v-for="(item, index) in items" :key="index">
{{ item }}
</li>
</ul>
</NcAppContent>
</NcContent>
</div>
</template>
<script>
import IconAccount from 'vue-material-design-icons/Account.vue'
import IconCalendarAccount from 'vue-material-design-icons/CalendarAccount.vue'
import IconFilter from 'vue-material-design-icons/Filter.vue'
import IconSearchGlobal from 'vue-material-design-icons/CloudSearch.vue'
import IconStar from 'vue-material-design-icons/Star.vue'
const exampleItem = ['Mary', 'Patricia', 'James', 'Michael']
export default {
components: {
IconAccount,
IconCalendarAccount,
IconFilter,
IconSearchGlobal,
IconStar,
},
data() {
return {
searchQuery: '',
}
},
computed: {
items() {
return exampleItem.filter((item) => item.toLocaleLowerCase().includes(this.searchQuery.toLocaleLowerCase()))
},
},
}
</script>
<style scoped>
/* This styles just mock NcContent and NcAppContent */
.content-styleguidist {
position: relative !important;
margin: 0 !important;
/* prevent jumping */
min-height: 200px;
}
.content-styleguidist > * {
height: auto;
}
.fake-content {
padding: var(--app-navigation-padding);
padding-top: calc(2 * var(--app-navigation-padding) + var(--default-clickable-area));
}
.styleguide-wrapper {
background-color: var(--color-background-plain);
padding: var(--body-container-margin);
}
</style>
```

</docs>

<template>
Expand All @@ -47,6 +158,17 @@ emit('toggle-navigation', {
:inert="!open || undefined"
@keydown.esc="handleEsc">
<div class="app-navigation__body" :class="{ 'app-navigation__body--no-list': !$scopedSlots.list }">
<NcAppNavigationSearch v-if="showSearch"
:label="searchLabel || undefined"
:no-inline-actions="noSearchInlineActions"
:value="search"
@update:value="$emit('update:search', $event)">
<template #actions>
<!-- @slot Optional actions, like NcActions or icon only buttons, to show next to the search input -->
<slot name="search-actions" />
</template>
</NcAppNavigationSearch>

<!-- The main content of the navigation. If no list is passed to the #list slot, stretched vertically. -->
<slot />
</div>
Expand All @@ -69,15 +191,17 @@ import { getTrapStack } from '../../utils/focusTrap.js'
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
import { createFocusTrap } from 'focus-trap'
import NcAppNavigationToggle from '../NcAppNavigationToggle/index.js'
import NcAppNavigationList from '../NcAppNavigationList/index.js'
import NcAppNavigationSearch from './NcAppNavigationSearch.vue'
import NcAppNavigationToggle from '../NcAppNavigationToggle/index.js'
import Vue from 'vue'
export default {
name: 'NcAppNavigation',
components: {
NcAppNavigationList,
NcAppNavigationSearch,
NcAppNavigationToggle,
},
Expand Down Expand Up @@ -105,6 +229,38 @@ export default {
type: String,
default: '',
},
/**
* If set an in-app search is shown as the first entry
*/
showSearch: {
type: Boolean,
default: false,
},
/**
* The current search query
*/
search: {
type: String,
default: '',
},
/**
* Label of in-app search input
*/
searchLabel: {
type: String,
default: null,
},
/**
* Force a menu if there is more than one search action
*/
noSearchInlineActions: {
type: Boolean,
default: false,
},
},
setup() {
Expand Down Expand Up @@ -159,7 +315,7 @@ export default {
* @param {boolean} [state] set the state instead of inverting the current one
*/
toggleNavigation(state) {
// Early return if alreay in that state
// Early return if already in that state
if (this.open === state) {
emit('navigation-toggled', {
open: this.open,
Expand Down Expand Up @@ -206,7 +362,7 @@ export default {
<style lang="scss">
.app-navigation,
.app-content {
/** Distance of the app naviation toggle and the first navigation item to the top edge of the app content container */
/** Distance of the app navigation toggle and the first navigation item to the top edge of the app content container */
--app-navigation-padding: #{$app-navigation-padding};
}
</style>
Expand Down
123 changes: 123 additions & 0 deletions src/components/NcAppNavigation/NcAppNavigationSearch.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="app-navigation-search"
:class="{
'app-navigation-search--has-actions': hasActions(),
}">
<NcInputField ref="inputElement"
:aria-label="label"
class="app-navigation-search__input"
label-outside
:placeholder="label"
show-trailing-button
:trailing-button-label="t('Clear search')"
type="search"
:value="value"
@update:value="$emit('update:value', $event)"
@trailing-button-click="onCloseSearch">
<template #trailing-button-icon>
<IconClose :size="20" />
</template>
</NcInputField>
<Transition v-if="hasActions()" name="slide-fade">
<div v-show="showActions"
ref="actionsContainer"
class="app-navigation-search__actions">
<slot name="actions" />
</div>
</Transition>
</div>
</template>

<script setup>
import { useFocusWithin } from '@vueuse/core'
import { ref, nextTick, useSlots, watch } from 'vue'
import { t } from '../../l10n.js'
import IconClose from 'vue-material-design-icons/Close.vue'
import NcInputField from '../NcInputField/NcInputField.vue'
defineProps({
/**
* Current search input
*/
value: {
type: String,
default: '',
},
/**
* Text used to label the search input
*/
label: {
type: String,
default: t('Search in app…'),
},
})
const emit = defineEmits(['update:value'])
const slots = useSlots()
const inputElement = ref()
const { focused: inputHasFocus } = useFocusWithin(inputElement)
/**
* @type {import('vue').Ref<import('vue').ComponentPublicInstance>}
*/
const actionsContainer = ref()
const hasActions = () => !!slots.actions
const showActions = ref(true)
watch(inputHasFocus, () => {
showActions.value = !inputHasFocus.value
})
/**
* Handle close the search
*/
function onCloseSearch() {
emit('update:value', '')
if (hasActions()) {
showActions.value = true
nextTick(() => actionsContainer.value.$el.querySelector('button')?.focus())
}
}
</script>
<style scoped lang="scss">
.app-navigation-search {
display: flex;
gap: var(--app-navigation-padding);
padding: var(--app-navigation-padding);
&--has-actions &__input {
flex-grow: 1;
z-index: 3;
}
&__actions {
display: flex;
gap: var(--default-grid-baseline);
margin-inline-start: 0;
max-width: calc(2 * var(--default-clickable-area) + var(--default-grid-baseline));
max-height: var(--default-clickable-area);
}
&__input {
// This is a fallback for legacy version (Nextcloud 29 and older) so that we keep the pill like design there
--input-border-radius: var(--border-radius-element, var(--border-radius-pill)) !important;
}
}
.slide-fade-enter-active,
.slide-fade-leave-active {
transition: margin-inline-start var(--animation-quick);
}
.slide-fade-enter,
.slide-fade-leave-to {
margin-inline-start: calc(-1 * var(--default-clickable-area));
}
</style>
1 change: 1 addition & 0 deletions styleguide.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ module.exports = async () => {
'src/components/NcAppNavigation*/*.vue',
],
ignore: [
'src/components/NcAppNavigation/NcAppNavigationSearch.vue',
'src/components/NcAppNavigationItem/NcAppNavigationIconCollapsible.vue',
'src/components/NcAppNavigationItem/NcInputConfirmCancel.vue',
],
Expand Down

0 comments on commit b24b457

Please sign in to comment.