Compare commits

...

28 Commits

Author SHA1 Message Date
Benjamin Canac
876f9578c2 chore(release): 1.2.1 2023-03-20 15:59:15 +01:00
Benjamin Canac
f69f584188 chore(deps): bump 2023-03-20 15:58:53 +01:00
Sylvain Marroufin
377b4189ca fix(defineShortcuts): shift + alphabetic character handling (#140) 2023-03-13 14:11:52 +01:00
Benjamin Canac
f76a9f0ab0 chore(release): 1.2.0 2023-03-09 16:55:06 +01:00
Sylvain Marroufin
37b2271bf0 fix(defineShortcuts): add missing import 2023-03-09 16:09:31 +01:00
Sylvain Marroufin
fa49d52f17 fix(Tooltip): shortcutsClass prop type 2023-03-09 15:37:42 +01:00
Sylvain Marroufin
fd4b608150 chore: add more composables (#138) 2023-03-09 11:42:22 +01:00
Sylvain Marroufin
fef93f3198 chore: allow preset override of components shortcuts (#139)
Co-authored-by: Benjamin Canac <canacb1@gmail.com>
2023-03-09 11:40:04 +01:00
Benjamin Canac
0826ef8d59 chore(deps): bump 2023-03-03 16:51:41 +01:00
Benjamin Canac
0e3066d865 chore(release): 1.1.4 2023-03-02 12:37:17 +01:00
Benjamin Canac
fb9d6cb544 chore(deps): freeze @headlessui/vue to 1.7.10 2023-03-02 12:36:55 +01:00
Benjamin Canac
531a89cdb8 chore(release): 1.1.3 2023-03-02 11:02:04 +01:00
Benjamin Canac
6970c2d665 chore(CommandPalette): add slot for command icon 2023-02-28 16:49:50 +01:00
Benjamin Canac
9719ea3858 chore(release): 1.1.2 2023-02-28 11:58:28 +01:00
Benjamin Canac
a4af6b3805 chore(deps): bump 2023-02-28 10:50:50 +01:00
Benjamin Canac
3493c138d9 chore: split props for Dropdown, ContextMenu and Popover 2023-02-24 17:33:57 +01:00
Benjamin Canac
d08e64d53f fix(Tooltip): truncate 2023-02-22 15:59:02 +01:00
renovate[bot]
6aecb082d2 chore(deps): update devdependency vue-tsc to ^1.1.7 (#132)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-22 12:31:21 +01:00
Sylvain Marroufin
63e27f8b4b chore(docs): add ContextMenu component page (#128) 2023-02-22 12:31:08 +01:00
Benjamin Canac
7970aefcb0 fix(VerticalNavigation): links to type 2023-02-22 12:22:54 +01:00
renovate[bot]
a893d7fa2e chore(deps): update all non-major dependencies (#124)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-21 19:26:03 +01:00
Benjamin Canac
8ace629ff8 chore(release): 1.1.1 2023-02-20 18:06:15 +01:00
Benjamin Canac
0d35b82ecb chore(CommandPalette): expose query 2023-02-20 18:05:52 +01:00
Benjamin Canac
5f37077835 types(CommandPalette): options no longer exists 2023-02-20 18:05:39 +01:00
Benjamin Canac
948f4b89b1 chore(release): 1.1.0 2023-02-17 19:08:09 +01:00
Benjamin Canac
e6d0dd5898 chore(CommandPalette): set debounce to 200 2023-02-17 19:07:38 +01:00
Benjamin Canac
4702a4f103 fix(CommandPalette): types 2023-02-17 18:14:11 +01:00
Benjamin Canac
efa9674815 feat(CommandPalette): handle async search for specific groups 2023-02-17 18:03:59 +01:00
24 changed files with 1239 additions and 509 deletions

View File

@@ -2,6 +2,47 @@
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
### [1.2.1](https://github.com/nuxtlabs/ui/compare/v1.2.0...v1.2.1) (2023-03-20)
### Bug Fixes
* **defineShortcuts:** shift + alphabetic character handling ([#140](https://github.com/nuxtlabs/ui/issues/140)) ([377b418](https://github.com/nuxtlabs/ui/commit/377b4189ca85603db0b7f040949260ba7494c46f))
## [1.2.0](https://github.com/nuxtlabs/ui/compare/v1.1.4...v1.2.0) (2023-03-09)
### Bug Fixes
* **defineShortcuts:** add missing import ([37b2271](https://github.com/nuxtlabs/ui/commit/37b2271bf04adfe6bee4d984fa12452b2168318c))
* **Tooltip:** `shortcutsClass` prop type ([fa49d52](https://github.com/nuxtlabs/ui/commit/fa49d52f17752eaa06f997a9b6e8df8adcab983f))
### [1.1.4](https://github.com/nuxtlabs/ui/compare/v1.1.3...v1.1.4) (2023-03-02)
### [1.1.3](https://github.com/nuxtlabs/ui/compare/v1.1.2...v1.1.3) (2023-03-02)
### [1.1.2](https://github.com/nuxtlabs/ui/compare/v1.1.1...v1.1.2) (2023-02-28)
### Bug Fixes
* **Tooltip:** truncate ([d08e64d](https://github.com/nuxtlabs/ui/commit/d08e64d53fa439f34d51909bcb6812f1bcd95d83))
* **VerticalNavigation:** links `to` type ([7970aef](https://github.com/nuxtlabs/ui/commit/7970aefcb032ce01fcb11e9285fa61ce87f59519))
### [1.1.1](https://github.com/nuxtlabs/ui/compare/v1.1.0...v1.1.1) (2023-02-20)
## [1.1.0](https://github.com/nuxtlabs/ui/compare/v1.0.0...v1.1.0) (2023-02-17)
### Features
* **CommandPalette:** handle async search for specific groups ([efa9674](https://github.com/nuxtlabs/ui/commit/efa9674815ab4de756079690da0a381c3703d564))
### Bug Fixes
* **CommandPalette:** types ([4702a4f](https://github.com/nuxtlabs/ui/commit/4702a4f10379201c167cc52099519778756a5780))
## [1.0.0](https://github.com/nuxtlabs/ui/compare/v0.2.1...v1.0.0) (2023-02-17)

View File

@@ -84,7 +84,7 @@ const sections = [
{ label: 'Forms', links: [{ label: 'Checkbox', to: '/components/Checkbox' }, { label: 'Input', to: '/components/Input' }, { label: 'FormGroup', to: '/components/FormGroup' }, { label: 'Radio', to: '/components/Radio' }, { label: 'Select', to: '/components/Select' }, { label: 'SelectCustom', to: '/components/SelectCustom' }, { label: 'Textarea', to: '/components/Textarea' }, { label: 'Toggle', to: '/components/Toggle' }] },
{ label: 'Layout', links: [{ label: 'Card', to: '/components/Card' }, { label: 'Container', to: '/components/Container' }] },
{ label: 'Navigation', links: [{ label: 'Pills', to: '/components/Pills' }, { label: 'Tabs', to: '/components/Tabs' }, { label: 'VerticalNavigation', to: '/components/VerticalNavigation' }, { label: 'CommandPalette', to: '/components/CommandPalette' }] },
{ label: 'Overlays', links: [{ label: 'Modal', to: '/components/Modal' }, { label: 'Notification', to: '/components/Notification' }, { label: 'Popover', to: '/components/Popover' }, { label: 'Slideover', to: '/components/Slideover' }, { label: 'Tooltip', to: '/components/Tooltip' }] }
{ label: 'Overlays', links: [{ label: 'ContextMenu', to: '/components/ContextMenu' }, { label: 'Modal', to: '/components/Modal' }, { label: 'Notification', to: '/components/Notification' }, { label: 'Popover', to: '/components/Popover' }, { label: 'Slideover', to: '/components/Slideover' }, { label: 'Tooltip', to: '/components/Tooltip' }] }
]
</script>

View File

@@ -3,6 +3,13 @@ import nuxtUI from '../src/module'
// https://v3.nuxtjs.org/docs/directory-structure/nuxt.config
export default defineNuxtConfig({
app: {
head: {
htmlAttrs: {
lang: 'en'
}
}
},
modules: [
// @ts-ignore
nuxtUI

View File

@@ -56,7 +56,6 @@
v-if="prop.type === 'Boolean'"
v-model="prop.value"
:name="prop.key"
:label="prop.key"
/>
<USelect
v-else-if="prop.values"
@@ -121,6 +120,31 @@ const toggle = ref(false)
const modal = ref(false)
const slideover = ref(false)
const x = ref(0)
const y = ref(0)
const isContextMenuOpen = ref(false)
const virtualElement = ref({ getBoundingClientRect: () => ({}) })
onMounted(() => {
document.addEventListener('mousemove', ({ clientX, clientY }) => {
x.value = clientX
y.value = clientY
})
})
function openContextMenu () {
const top = unref(y)
const left = unref(x)
virtualElement.value.getBoundingClientRect = () => ({
width: 0,
height: 0,
top,
left
})
isContextMenuOpen.value = true
}
const defaultProps = {
Button: {
label: 'Button text'
@@ -261,6 +285,31 @@ const defaultProps = {
title: 'Notification title',
callback: 'console.log(\'Timer expired\')'
},
ContextMenu: {
modelValue: isContextMenuOpen,
'onUpdate:modelValue': (v) => { isContextMenuOpen.value = v },
virtualElement,
component: {
name: 'Card',
props: {
variant: 'secondary',
label: 'Open context menu',
onClick: () => { isContextMenuOpen.value = false },
onContextmenu: (e) => {
e?.preventDefault()
openContextMenu()
},
class: 'relative w-[300px] h-[100px]'
}
},
slots: {
default: {
tag: 'div',
html: 'Context menu content',
class: 'rounded border u-border-gray-200 p-2'
}
}
},
Modal: {
modelValue: modal,
'onUpdate:modelValue': (v) => { modal.value = v },

View File

@@ -104,7 +104,7 @@
</template>
<template #panel>
<div class="p-4 u-bg-white border u-border-gray-200 rounded-md">
<div class="p-2">
Panel
</div>
</template>
@@ -128,16 +128,26 @@
<UButton icon="i-heroicons-bell" variant="red" label="Trigger an error" @click="onNotificationClick" />
</div>
<div>
<div class="font-medium text-sm mb-1 u-text-gray-700">
Copy text to clipboard:
</div>
<div class="flex gap-2">
<UInput v-model="textToCopy" name="textToCopy" />
<UButton icon="i-heroicons-document-duplicate-solid" variant="primary" label="Copy text" @click="onCopyTextClick" />
</div>
</div>
<div>
<div class="font-medium text-sm mb-1 u-text-gray-700">
Context menu:
</div>
<UCard ref="contextMenuRef" class="relative" body-class="h-64" @click="isContextMenuOpen = false" @contextmenu.prevent="openContextMenu">
<UCard class="relative" body-class="h-64" @click="isContextMenuOpen = false" @contextmenu.prevent="openContextMenu">
<UContextMenu v-model="isContextMenuOpen" :virtual-element="virtualElement" width-class="w-48">
<UCard @click.stop>
<div class="p-2">
Menu
</UCard>
</div>
</UContextMenu>
</UCard>
</div>
@@ -149,18 +159,13 @@
<UCard body-class="">
<UCommandPalette
v-model="form.persons"
multiple
:placeholder="false"
:options="{
fuseOptions: {
includeMatches: true
}
}"
:groups="[{
key: 'persons',
commands: people
}]"
:groups="commandPaletteGroups"
command-attribute="name"
/>
</UCard>
@@ -235,6 +240,7 @@
<script setup>
const isModalOpen = ref(false)
const textToCopy = ref('Copied text')
const people = ref([
{ id: 1, name: 'Durward Reynolds', disabled: false },
@@ -256,12 +262,25 @@ const form = reactive({
persons: ref([people.value[0]])
})
const { $toast } = useNuxtApp()
const toast = useToast()
const clipboard = useCopyToClipboard()
const x = ref(0)
const y = ref(0)
const isContextMenuOpen = ref(false)
const contextMenuRef = ref(null)
const virtualElement = ref({ getBoundingClientRect: () => ({}) })
const commandPaletteGroups = computed(() => ([{
key: 'people',
commands: people.value
}, {
key: 'search',
label: q => q && `Search results for "${q}"...`,
search: async (q) => {
if (!q) { return [] }
return await $fetch(`https://jsonplaceholder.typicode.com/users?q=${q}`)
}
}]))
onMounted(() => {
document.addEventListener('mousemove', ({ clientX, clientY }) => {
@@ -270,8 +289,6 @@ onMounted(() => {
})
})
const virtualElement = ref({ getBoundingClientRect: () => ({}) })
function openContextMenu () {
const top = unref(y)
const left = unref(x)
@@ -341,6 +358,10 @@ const customDropdownItems = [
]
const onNotificationClick = () => {
$toast.error({ title: 'Error', description: 'This is an error message' })
toast.error({ title: 'Error', description: 'This is an error message' })
}
const onCopyTextClick = () => {
clipboard.copy(textToCopy.value, { title: 'Text copied successfully!' })
}
</script>

View File

@@ -1,6 +1,6 @@
{
"name": "@nuxthq/ui",
"version": "1.0.0",
"version": "1.2.1",
"repository": "https://github.com/nuxtlabs/ui",
"license": "MIT",
"exports": {
@@ -25,34 +25,35 @@
"release": "yarn lint && standard-version && git push --follow-tags"
},
"dependencies": {
"@egoist/tailwindcss-icons": "^1.0.5",
"@headlessui/vue": "^1.7.10",
"@iconify-json/heroicons": "^1.1.9",
"@nuxt/kit": "^3.2.0",
"@egoist/tailwindcss-icons": "^1.0.7",
"@headlessui/vue": "^1.7.12",
"@iconify-json/heroicons": "^1.1.10",
"@nuxt/kit": "^3.3.1",
"@nuxtjs/color-mode": "^3.2.0",
"@nuxtjs/tailwindcss": "^6.4.1",
"@nuxtjs/tailwindcss": "^6.6.0",
"@popperjs/core": "^2.11.6",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/line-clamp": "^0.4.2",
"@tailwindcss/typography": "^0.5.9",
"@vueuse/core": "^9.12.0",
"@vueuse/integrations": "^9.12.0",
"@vueuse/core": "^9.13.0",
"@vueuse/integrations": "^9.13.0",
"@vueuse/math": "^9.13.0",
"defu": "^6.1.2",
"fuse.js": "^6.6.2",
"lodash-es": "^4.17.21",
"tailwindcss": "^3.2.7"
},
"devDependencies": {
"@iconify-json/mdi": "^1.1.47",
"@iconify-json/mdi": "^1.1.50",
"@nuxt/module-builder": "^0.2.1",
"@nuxtjs/eslint-config-typescript": "^12.0.0",
"@types/lodash-es": "^4.17.6",
"@types/node": "^18.13.0",
"eslint": "^8.34.0",
"nuxt": "^3.2.0",
"@types/lodash-es": "^4.17.7",
"@types/node": "^18.15.3",
"eslint": "^8.36.0",
"nuxt": "^3.3.1",
"standard-version": "^9.5.0",
"unbuild": "^1.1.2",
"vue-tsc": "^1.1.2"
"vue-tsc": "^1.2.0"
}
}

View File

@@ -1,4 +1,4 @@
import { defineNuxtModule, installModule, addComponentsDir, addImportsDir, addTemplate, addPlugin, createResolver } from '@nuxt/kit'
import { defineNuxtModule, installModule, addComponentsDir, addImportsDir, addTemplate, createResolver } from '@nuxt/kit'
import { defu } from 'defu'
import colors from 'tailwindcss/colors.js'
import type { Config } from 'tailwindcss'
@@ -155,9 +155,6 @@ export default defineNuxtModule<ModuleOptions>({
cssPath: resolve(runtimeDir, 'tailwind.css')
})
addPlugin(resolve(runtimeDir, 'plugins', 'toast.client'))
addPlugin(resolve(runtimeDir, 'plugins', 'clipboard.client'))
addComponentsDir({
path: resolve(runtimeDir, 'components', 'elements'),
prefix,

View File

@@ -17,7 +17,7 @@
<div v-if="open && items.length" ref="container" :class="[containerClass, widthClass]" @mouseover="onMouseOver">
<transition appear v-bind="transitionClass">
<MenuItems :class="baseClass" static>
<MenuItems :class="[baseClass, divideClass, ringClass, roundedClass, shadowClass, backgroundClass]" static>
<div v-for="(subItems, index) of items" :key="index" :class="groupClass">
<MenuItem v-for="(item, subIndex) of subItems" :key="subIndex" v-slot="{ active, disabled: itemDisabled }" :disabled="item.disabled">
<Component
@@ -104,6 +104,26 @@ const props = defineProps({
type: String,
default: () => $ui.dropdown.width
},
backgroundClass: {
type: String,
default: () => $ui.dropdown.background
},
shadowClass: {
type: String,
default: () => $ui.dropdown.shadow
},
roundedClass: {
type: String,
default: () => $ui.dropdown.rounded
},
ringClass: {
type: String,
default: () => $ui.dropdown.ring
},
divideClass: {
type: String,
default: () => $ui.dropdown.divide
},
baseClass: {
type: String,
default: () => $ui.dropdown.base

View File

@@ -38,7 +38,14 @@
aria-label="Commands"
class="relative flex-1 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-800 scroll-py-2"
>
<CommandPaletteGroup v-for="group of groups" :key="group.key" :group="group" :group-attribute="groupAttribute" :command-attribute="commandAttribute">
<CommandPaletteGroup
v-for="group of groups"
:key="group.key"
:query="query"
:group="group"
:group-attribute="groupAttribute"
:command-attribute="commandAttribute"
>
<template v-for="(_, name) in $slots" #[name]="slotData">
<slot :name="name" v-bind="slotData" />
</template>
@@ -59,6 +66,7 @@
import { ref, computed, watch, onMounted } from 'vue'
import { Combobox, ComboboxInput, ComboboxOptions } from '@headlessui/vue'
import type { ComputedRef, PropType, ComponentPublicInstance } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import { useFuse } from '@vueuse/integrations/useFuse'
import { groupBy, map } from 'lodash-es'
import { defu } from 'defu'
@@ -133,6 +141,10 @@ const props = defineProps({
placeholder: {
type: Boolean,
default: true
},
debounce: {
type: Number,
default: 200
}
})
@@ -168,29 +180,44 @@ const options: ComputedRef<Partial<UseFuseOptions<Command>>> = computed(() => de
matchAllWhenSearchEmpty: true
}))
const commands = computed(() => props.groups.reduce((acc, group) => {
const commands = computed(() => props.groups.filter(group => !group.search).reduce((acc, group) => {
return acc.concat(group.commands.map(command => ({ ...command, group: group.key })))
}, [] as Command[]))
const searchResults = ref<{ [key: string]: any }>({})
const { results } = useFuse(query, commands, options)
const groups = computed(() => map(groupBy(results.value, command => command.item.group), (results, key) => {
const commands = results.map((result) => {
const { item, ...data } = result
const groups = computed(() => ([
...map(groupBy(results.value, command => command.item.group), (results, key) => {
const commands = results.map((result) => {
const { item, ...data } = result
return {
...item,
...data
}
})
return {
...item,
...data
}
})
...props.groups.find(group => group.key === key),
commands: commands.slice(0, options.value.resultLimit)
} as Group
}),
...props.groups.filter(group => !!group.search).map(group => ({ ...group, commands: (searchResults.value[group.key] || []).slice(0, options.value.resultLimit) })).filter(group => group.commands.length)
]))
return {
...props.groups.find(group => group.key === key),
commands: commands.slice(0, options.value.resultLimit)
} as Group
}))
const debouncedSearch = useDebounceFn(async () => {
const searchableGroups = props.groups.filter(group => !!group.search)
await Promise.all(searchableGroups.map(async (group) => {
searchResults.value[group.key] = await group.search(query.value)
}))
}, props.debounce)
watch(query, () => {
debouncedSearch()
// Select first item on search changes
setTimeout(() => {
// https://github.com/tailwindlabs/headlessui/blob/6fa6074cd5d3a96f78a2d965392aa44101f5eede/packages/%40headlessui-vue/src/components/combobox/combobox.ts#L804
@@ -228,6 +255,7 @@ function onClear () {
}
defineExpose({
query,
updateQuery: (q: string) => {
query.value = q
},

View File

@@ -1,7 +1,7 @@
<template>
<div class="p-2" role="option">
<h2 v-if="group[groupAttribute]" class="px-3 my-2 text-xs font-semibold u-text-gray-900">
{{ group[groupAttribute] }}
<h2 v-if="label" class="px-3 my-2 text-xs font-semibold u-text-gray-900">
{{ label }}
</h2>
<div class="text-sm u-text-gray-700" role="listbox" :aria-label="group[groupAttribute]">
@@ -15,14 +15,16 @@
>
<div :class="['flex justify-between select-none items-center rounded-md px-3 py-2 gap-3 relative', active && 'bg-gray-100 dark:bg-gray-800 u-text-gray-900', command.disabled ? 'cursor-not-allowed' : 'cursor-pointer']">
<div class="flex items-center gap-2 min-w-0">
<Icon v-if="command.icon" :name="command.icon" :class="['h-4 w-4 flex-shrink-0', active ? 'text-opacity-100 dark:text-opacity-100' : 'text-opacity-40 dark:text-opacity-40', command.iconClass || 'text-gray-900 dark:text-gray-50']" aria-hidden="true" />
<Avatar
v-else-if="command.avatar"
v-bind="{ size: 'xxxs', ...command.avatar }"
class="flex-shrink-0"
aria-hidden="true"
/>
<span v-else-if="command.chip" class="flex-shrink-0 w-2 h-2 mx-1 rounded-full" :style="{ background: `#${command.chip}` }" />
<slot :name="`${group.key}-icon`" :group="group" :command="command">
<Icon v-if="command.icon" :name="command.icon" :class="['h-4 w-4 flex-shrink-0', active ? 'text-opacity-100 dark:text-opacity-100' : 'text-opacity-40 dark:text-opacity-40', command.iconClass || 'text-gray-900 dark:text-gray-50']" aria-hidden="true" />
<Avatar
v-else-if="command.avatar"
v-bind="{ size: 'xxxs', ...command.avatar }"
class="flex-shrink-0"
aria-hidden="true"
/>
<span v-else-if="command.chip" class="flex-shrink-0 w-2 h-2 mx-1 rounded-full" :style="{ background: `#${command.chip}` }" />
</slot>
<div class="flex items-center gap-1.5 min-w-0" :class="{ 'opacity-50': command.disabled }">
<slot :name="`${group.key}-command`" :group="group" :command="command">
@@ -40,7 +42,7 @@
<span v-if="group.active" class="flex-shrink-0 u-text-gray-500">{{ group.active }}</span>
</slot>
<slot v-else :name="`${group.key}-inactive`" :group="group" :command="command">
<span v-if="command.shortcuts?.length" class="flex-shrink-0 text-xs font-semibold u-text-gray-500">
<span v-if="command.shortcuts?.length" :class="$ui.commandPalette.option.shortcuts">
<kbd v-for="shortcut of command.shortcuts" :key="shortcut" class="font-sans">{{ shortcut }}</kbd>
</span>
<span v-else-if="!command.disabled && group.inactive" class="flex-shrink-0 u-text-gray-500">{{ group.inactive }}</span>
@@ -52,6 +54,7 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ComboboxOption } from '@headlessui/vue'
import type { PropType } from 'vue'
import Icon from '../elements/Icon.vue'
@@ -59,11 +62,15 @@ import Avatar from '../elements/Avatar.vue'
import type { Group } from '../../types/command-palette'
import $ui from '#build/ui'
defineProps({
const props = defineProps({
group: {
type: Object as PropType<Group>,
required: true
},
query: {
type: String,
default: ''
},
groupAttribute: {
type: String,
required: true
@@ -74,6 +81,12 @@ defineProps({
}
})
const label = computed(() => {
const label = props.group[props.groupAttribute]
return typeof label === 'function' ? label(props.query) : label
})
function highlight ({ indices, value }: { indices: number[][], value:string }, i = 1): string {
const pair = indices[indices.length - i]
if (!pair) {

View File

@@ -49,7 +49,7 @@ import $ui from '#build/ui'
defineProps({
links: {
type: Array as PropType<{
to?: RouteLocationNormalized
to?: RouteLocationNormalized | string
exact?: boolean
label: string
icon?: string

View File

@@ -1,7 +1,7 @@
<template>
<div v-if="isOpen" ref="container" :class="[containerClass, widthClass]">
<transition appear v-bind="transitionClass">
<div :class="baseClass">
<div :class="[baseClass, ringClass, roundedClass, shadowClass, backgroundClass]">
<slot />
</div>
</transition>
@@ -38,6 +38,22 @@ const props = defineProps({
type: String,
default: () => $ui.contextMenu.width
},
backgroundClass: {
type: String,
default: () => $ui.contextMenu.background
},
shadowClass: {
type: String,
default: () => $ui.contextMenu.shadow
},
roundedClass: {
type: String,
default: () => $ui.contextMenu.rounded
},
ringClass: {
type: String,
default: () => $ui.contextMenu.ring
},
baseClass: {
type: String,
default: () => $ui.contextMenu.base

View File

@@ -9,7 +9,7 @@
v-bind="notification"
:class="notification.click && 'cursor-pointer'"
@click="notification.click && notification.click(notification)"
@close="$toast.removeNotification(notification.id)"
@close="toast.removeNotification(notification.id)"
/>
</div>
</div>
@@ -18,10 +18,11 @@
<script setup lang="ts">
import type { ToastNotification } from '../../types'
import { useToast } from '../../composables/useToast'
import Notification from './Notification.vue'
import { useNuxtApp, useState } from '#imports'
import { useState } from '#imports'
const { $toast } = useNuxtApp()
const toast = useToast()
const notifications = useState<ToastNotification[]>('notifications', () => [])
</script>

View File

@@ -17,7 +17,7 @@
<div v-if="open" ref="container" :class="[containerClass, widthClass]" @mouseover="onMouseOver">
<transition appear v-bind="transitionClass">
<PopoverPanel :class="baseClass" static>
<PopoverPanel :class="[baseClass, ringClass, roundedClass, shadowClass, backgroundClass]" static>
<slot name="panel" :open="open" :close="close" />
</PopoverPanel>
</transition>
@@ -62,6 +62,22 @@ const props = defineProps({
type: String,
default: () => $ui.popover.base
},
backgroundClass: {
type: String,
default: () => $ui.popover.background
},
shadowClass: {
type: String,
default: () => $ui.popover.shadow
},
roundedClass: {
type: String,
default: () => $ui.popover.rounded
},
ringClass: {
type: String,
default: () => $ui.popover.ring
},
transitionClass: {
type: Object,
default: () => $ui.popover.transition

View File

@@ -8,10 +8,10 @@
<transition appear v-bind="transitionClass">
<div :class="[baseClass, backgroundClass, roundedClass, shadowClass, ringClass]">
<slot name="text">
<span class="truncate">{{ text }}</span>
{{ text }}
</slot>
<span v-if="shortcuts?.length" class="inline-flex items-center justify-end flex-shrink-0 gap-0.5 ml-1">
<span v-if="shortcuts?.length" :class="shortcutsClass">
<span class="mr-1 u-text-gray-700">&middot;</span>
<kbd v-for="shortcut of shortcuts" :key="shortcut" class="flex items-center justify-center font-sans px-1 h-4 min-w-[16px] text-[10px] u-bg-gray-100 rounded u-text-gray-900">
{{ shortcut }}
@@ -84,6 +84,10 @@ const props = defineProps({
type: Object as PropType<PopperOptions>,
default: () => ({})
},
shortcutsClass: {
type: String,
default: () => $ui.tooltip.shortcuts
},
openDelay: {
type: Number,
default: 0

View File

@@ -0,0 +1,109 @@
import type { Ref, ComputedRef } from 'vue'
import { logicAnd, logicNot } from '@vueuse/math'
import { computed, onMounted, onBeforeUnmount } from 'vue'
import { useShortcuts } from './useShortcuts'
export interface ShortcutConfig {
handler: Function
usingInput?: string | boolean
whenever?: Ref<Boolean>[]
}
export interface ShortcutsConfig {
[key: string]: ShortcutConfig | Function
}
interface Shortcut {
handler: Function
condition: ComputedRef<Boolean>
// KeyboardEvent attributes
key: string
ctrlKey: boolean
metaKey: boolean
shiftKey: boolean
altKey: boolean
// code?: string
// keyCode?: number
}
export const defineShortcuts = (config: ShortcutsConfig) => {
const { macOS, usingInput } = useShortcuts()
let shortcuts: Shortcut[] = []
const onKeyDown = (e: KeyboardEvent) => {
const alphabeticalKey = /^[a-z]{1}$/i.test(e.key)
for (const shortcut of shortcuts) {
if (e.key.toLowerCase() !== shortcut.key) { continue }
if (e.metaKey !== shortcut.metaKey) { continue }
if (e.ctrlKey !== shortcut.ctrlKey) { continue }
// shift modifier is only checked in combination with alphabetical keys
// (shift with non-alphabetical keys would change the key)
if (alphabeticalKey && e.shiftKey !== shortcut.shiftKey) { continue }
// alt modifier changes the combined key anyways
// if (e.altKey !== shortcut.altKey) { continue }
if (shortcut.condition.value) {
e.preventDefault()
shortcut.handler()
}
return
}
}
onMounted(() => {
// Map config to full detailled shortcuts
shortcuts = Object.entries(config).map(([key, shortcutConfig]) => {
if (!shortcutConfig) {
return null
}
// Parse key and modifiers
const keySplit = key.toLowerCase().split('_').map(k => k)
let shortcut: Partial<Shortcut> = {
key: keySplit.filter(k => !['meta', 'ctrl', 'shift', 'alt'].includes(k)).join('_'),
metaKey: keySplit.includes('meta'),
ctrlKey: keySplit.includes('ctrl'),
shiftKey: keySplit.includes('shift'),
altKey: keySplit.includes('alt')
}
// Convert Meta to Ctrl for non-MacOS
if (!macOS.value && shortcut.metaKey && !shortcut.ctrlKey) {
shortcut.metaKey = false
shortcut.ctrlKey = true
}
// Retrieve handler function
if (typeof shortcutConfig === 'function') {
shortcut.handler = shortcutConfig
} else if (typeof shortcutConfig === 'object') {
shortcut = { ...shortcut, handler: shortcutConfig.handler }
}
if (!shortcut.handler) {
// eslint-disable-next-line no-console
console.trace('[Shortcut] Invalid value')
return null
}
// Create shortcut computed
const conditions = []
if (!(shortcutConfig as ShortcutConfig).usingInput) {
conditions.push(logicNot(usingInput))
} else if (typeof (shortcutConfig as ShortcutConfig).usingInput === 'string') {
conditions.push(computed(() => usingInput.value === (shortcutConfig as ShortcutConfig).usingInput))
}
shortcut.condition = logicAnd(...conditions, ...((shortcutConfig as ShortcutConfig).whenever || []))
return shortcut as Shortcut
}).filter(Boolean) as Shortcut[]
document.addEventListener('keydown', onKeyDown)
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeyDown)
})
}

View File

@@ -1,8 +1,9 @@
import { useClipboard } from '@vueuse/core'
import { defineNuxtPlugin } from '#app'
import { useToast } from './useToast'
export default defineNuxtPlugin((nuxtApp) => {
export function useCopyToClipboard () {
const { copy: copyToClipboard, isSupported } = useClipboard()
const toast = useToast()
function copy (text: string, success: { title?: string, description?: string } = {}, failure: { title?: string, description?: string } = {}) {
if (!isSupported) {
@@ -14,9 +15,9 @@ export default defineNuxtPlugin((nuxtApp) => {
return
}
nuxtApp.$toast.success(success)
toast.success(success)
}, function (e) {
nuxtApp.$toast.error({
toast.error({
...failure,
description: failure.description || e.message
})
@@ -24,10 +25,6 @@ export default defineNuxtPlugin((nuxtApp) => {
}
return {
provide: {
clipboard: {
copy
}
}
copy
}
})
}

View File

@@ -0,0 +1,32 @@
import { createSharedComposable, useActiveElement } from '@vueuse/core'
import { ref, computed, onMounted } from 'vue'
export const _useShortcuts = () => {
const macOS = computed(() => process.client && navigator && navigator.userAgent && navigator.userAgent.match(/Macintosh;/))
const metaSymbol = ref(' ')
const activeElement = useActiveElement()
const usingInput = computed(() => {
const usingInput = !!(activeElement.value?.tagName === 'INPUT' || activeElement.value?.tagName === 'TEXTAREA' || activeElement.value?.contentEditable === 'true')
if (usingInput) {
return ((activeElement.value as any)?.name as string) || true
}
return false
})
onMounted(() => {
metaSymbol.value = macOS.value ? '⌘' : 'Ctrl'
})
return {
macOS,
metaSymbol,
activeElement,
usingInput
}
}
export const useShortcuts = createSharedComposable(_useShortcuts)

View File

@@ -0,0 +1,41 @@
import type { ToastNotification } from '../types'
import { useState } from '#imports'
export function useToast () {
const notifications = useState<ToastNotification[]>('notifications', () => [])
function addNotification (notification: Partial<ToastNotification>) {
const body = {
id: new Date().getTime().toString(),
...notification
}
const index = notifications.value.findIndex((n: ToastNotification) => n.id === body.id)
if (index === -1) {
notifications.value.push(body as ToastNotification)
}
return body
}
function removeNotification (id: string) {
notifications.value = notifications.value.filter((n: ToastNotification) => n.id !== id)
}
const success = (notification: Partial<ToastNotification> = {}) => addNotification({ type: 'success', ...notification })
const info = (notification: Partial<ToastNotification> = {}) => addNotification({ type: 'info', ...notification })
const warning = (notification: Partial<ToastNotification> = {}) => addNotification({ type: 'warning', ...notification })
const error = (notification: Partial<ToastNotification>) => addNotification({ type: 'error', title: 'An error occurred!', ...notification })
return {
addNotification,
removeNotification,
success,
info,
warning,
error
}
}

View File

@@ -1,45 +0,0 @@
import { defineNuxtPlugin, useState } from '#app'
import type { ToastNotification } from '../types'
export default defineNuxtPlugin(() => {
const notifications = useState<ToastNotification[]>('notifications', () => [])
function addNotification (notification: Partial<ToastNotification>) {
const body = {
id: new Date().getTime().toString(),
...notification
}
const index = notifications.value.findIndex((n: ToastNotification) => n.id === body.id)
if (index === -1) {
notifications.value.push(body as ToastNotification)
}
return body
}
function removeNotification (id: string) {
notifications.value = notifications.value.filter((n: ToastNotification) => n.id !== id)
}
return {
provide: {
toast: {
addNotification,
removeNotification,
success (notification: Partial<ToastNotification> = {}) {
return addNotification({ type: 'success', ...notification })
},
info (notification: Partial<ToastNotification> = {}) {
return addNotification({ type: 'info', ...notification })
},
warning (notification: Partial<ToastNotification> = {}) {
return addNotification({ type: 'warning', ...notification })
},
error (notification: Partial<ToastNotification>) {
return addNotification({ type: 'error', title: 'An error occurred!', ...notification })
}
}
}
}
})

View File

@@ -366,7 +366,12 @@ export default function defaultPreset (variantColors: string[]) {
wrapper: 'relative inline-flex text-left',
container: 'z-20',
width: 'w-48',
base: 'u-bg-white divide-y u-divide-gray-100 rounded-md ring-1 u-ring-gray-200 shadow-lg focus:outline-none',
background: 'u-bg-white',
shadow: 'shadow-lg',
rounded: 'rounded-md',
ring: 'ring-1 u-ring-gray-200',
base: 'focus:outline-none',
divide: 'divide-y u-divide-gray-100',
group: 'py-1',
item: {
base: 'group flex items-center gap-3 px-4 py-2 text-sm w-full',
@@ -375,7 +380,7 @@ export default function defaultPreset (variantColors: string[]) {
disabled: 'cursor-not-allowed opacity-50',
icon: 'h-5 w-5 u-text-gray-400 group-hover:u-text-gray-500 flex-shrink-0',
avatar: '-m-0.5 group-hover:u-bg-gray-200 flex-shrink-0',
shortcuts: 'flex-shrink-0 text-xs font-semibold u-text-gray-500 ml-auto'
shortcuts: 'hidden md:inline-flex flex-shrink-0 text-xs font-semibold u-text-gray-500 ml-auto'
},
transition: {
enterActiveClass: 'transition duration-100 ease-out',
@@ -520,7 +525,8 @@ export default function defaultPreset (variantColors: string[]) {
shadow: 'shadow',
rounded: 'rounded',
ring: 'ring-1 u-ring-gray-200',
base: 'invisible lg:visible h-6 px-2 py-1 text-xs font-normal',
base: 'invisible lg:visible h-6 px-2 py-1 text-xs font-normal truncate',
shortcuts: 'hidden md:inline-flex items-center justify-end flex-shrink-0 gap-0.5 ml-1',
transition: {
enterActiveClass: 'transition ease-out duration-200',
enterFromClass: 'opacity-0 translate-y-1',
@@ -538,7 +544,11 @@ export default function defaultPreset (variantColors: string[]) {
wrapper: 'relative',
container: 'z-20',
width: '',
base: '',
background: 'u-bg-white',
shadow: 'shadow-lg',
rounded: 'rounded-md',
ring: 'ring-1 u-ring-gray-200',
base: 'overflow-hidden focus:outline-none',
transition: {
enterActiveClass: 'transition ease-out duration-200',
enterFromClass: 'opacity-0 translate-y-1',
@@ -556,7 +566,11 @@ export default function defaultPreset (variantColors: string[]) {
wrapper: 'relative',
container: 'z-20',
width: '',
base: '',
background: 'u-bg-white',
shadow: 'shadow-lg',
rounded: 'rounded-md',
ring: 'ring-1 u-ring-gray-200',
base: 'overflow-hidden focus:outline-none',
transition: {
enterActiveClass: 'transition ease-out duration-200',
enterFromClass: 'opacity-0 translate-y-1',
@@ -598,7 +612,8 @@ export default function defaultPreset (variantColors: string[]) {
icon: {
name: 'i-heroicons-check-20-solid'
}
}
},
shortcuts: 'hidden md:inline-flex flex-shrink-0 text-xs font-semibold u-text-gray-500'
}
}

View File

@@ -1,6 +1,7 @@
import type { UseFuseOptions } from '@vueuse/integrations/useFuse'
import type { FuseSortFunctionMatch, FuseSortFunctionMatchList } from 'fuse.js'
import type { Avatar } from './avatar'
export interface Command {
id: string | number
prefix?: string
@@ -22,6 +23,5 @@ export interface Group {
active?: string
inactive?: string
commands: Command[]
options?: Partial<UseFuseOptions<Command>>
[key: string]: any
}

View File

@@ -14,10 +14,3 @@ export interface ToastNotification {
click?: Function
callback?: Function
}
export interface ToastPlugin {
addNotification: (notification: Partial<Notification>) => Notification
removeNotification: (id: string) => void
success: (options: { title?: string, description?: string }) => void
error: (options: { title?: string, description?: string }) => void
}

1114
yarn.lock

File diff suppressed because it is too large Load Diff