mirror of
https://github.com/CorentinTh/it-tools.git
synced 2025-04-20 06:55:06 -04:00
refactor(search): command palette design (#463)
This commit is contained in:
parent
732da08157
commit
bcb98b359c
18 changed files with 576 additions and 386 deletions
|
@ -6,7 +6,7 @@ Useful tools for developer and people working in IT. [Have a look !](https://it-
|
||||||
|
|
||||||
Please check the [issues](https://github.com/CorentinTh/it-tools/issues) to see if some feature listed to be implemented.
|
Please check the [issues](https://github.com/CorentinTh/it-tools/issues) to see if some feature listed to be implemented.
|
||||||
|
|
||||||
You have an idea of a tool? Submit a [feature request](https://github.com/CorentinTh/it-tools/issues/new?assignees=corentinth&labels=&template=feature_request.md&title=)!
|
You have an idea of a tool? Submit a [feature request](https://github.com/CorentinTh/it-tools/issues/new/choose)!
|
||||||
|
|
||||||
## Self host
|
## Self host
|
||||||
|
|
||||||
|
|
4
components.d.ts
vendored
4
components.d.ts
vendored
|
@ -33,9 +33,13 @@ declare module '@vue/runtime-core' {
|
||||||
'CInputText.demo': typeof import('./src/ui/c-input-text/c-input-text.demo.vue')['default']
|
'CInputText.demo': typeof import('./src/ui/c-input-text/c-input-text.demo.vue')['default']
|
||||||
CLink: typeof import('./src/ui/c-link/c-link.vue')['default']
|
CLink: typeof import('./src/ui/c-link/c-link.vue')['default']
|
||||||
'CLink.demo': typeof import('./src/ui/c-link/c-link.demo.vue')['default']
|
'CLink.demo': typeof import('./src/ui/c-link/c-link.demo.vue')['default']
|
||||||
|
CModal: typeof import('./src/ui/c-modal/c-modal.vue')['default']
|
||||||
|
'CModal.demo': typeof import('./src/ui/c-modal/c-modal.demo.vue')['default']
|
||||||
CollapsibleToolMenu: typeof import('./src/components/CollapsibleToolMenu.vue')['default']
|
CollapsibleToolMenu: typeof import('./src/components/CollapsibleToolMenu.vue')['default']
|
||||||
ColorConverter: typeof import('./src/tools/color-converter/color-converter.vue')['default']
|
ColorConverter: typeof import('./src/tools/color-converter/color-converter.vue')['default']
|
||||||
ColoredCard: typeof import('./src/components/ColoredCard.vue')['default']
|
ColoredCard: typeof import('./src/components/ColoredCard.vue')['default']
|
||||||
|
CommandPalette: typeof import('./src/modules/command-palette/command-palette.vue')['default']
|
||||||
|
CommandPaletteOption: typeof import('./src/modules/command-palette/components/command-palette-option.vue')['default']
|
||||||
CrontabGenerator: typeof import('./src/tools/crontab-generator/crontab-generator.vue')['default']
|
CrontabGenerator: typeof import('./src/tools/crontab-generator/crontab-generator.vue')['default']
|
||||||
DateTimeConverter: typeof import('./src/tools/date-time-converter/date-time-converter.vue')['default']
|
DateTimeConverter: typeof import('./src/tools/date-time-converter/date-time-converter.vue')['default']
|
||||||
DemoWrapper: typeof import('./src/ui/demo/demo-wrapper.vue')['default']
|
DemoWrapper: typeof import('./src/ui/demo/demo-wrapper.vue')['default']
|
||||||
|
|
|
@ -74,7 +74,7 @@
|
||||||
"ts-pattern": "^4.2.2",
|
"ts-pattern": "^4.2.2",
|
||||||
"ua-parser-js": "^1.0.35",
|
"ua-parser-js": "^1.0.35",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"vue": "^3.2.47",
|
"vue": "^3.3.4",
|
||||||
"vue-i18n": "^9.2.2",
|
"vue-i18n": "^9.2.2",
|
||||||
"vue-router": "^4.1.6",
|
"vue-router": "^4.1.6",
|
||||||
"xml-formatter": "^3.3.2",
|
"xml-formatter": "^3.3.2",
|
||||||
|
@ -105,6 +105,7 @@
|
||||||
"@vitejs/plugin-vue-jsx": "^1.3.10",
|
"@vitejs/plugin-vue-jsx": "^1.3.10",
|
||||||
"@vue/compiler-sfc": "^3.2.47",
|
"@vue/compiler-sfc": "^3.2.47",
|
||||||
"@vue/runtime-core": "^3.2.47",
|
"@vue/runtime-core": "^3.2.47",
|
||||||
|
"@vue/runtime-dom": "^3.3.4",
|
||||||
"@vue/test-utils": "^2.3.2",
|
"@vue/test-utils": "^2.3.2",
|
||||||
"@vue/tsconfig": "^0.1.3",
|
"@vue/tsconfig": "^0.1.3",
|
||||||
"c8": "^7.13.0",
|
"c8": "^7.13.0",
|
||||||
|
|
380
pnpm-lock.yaml
generated
380
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
@ -1,109 +0,0 @@
|
||||||
<script lang="ts" setup>
|
|
||||||
import { SearchRound } from '@vicons/material';
|
|
||||||
import { useMagicKeys, whenever } from '@vueuse/core';
|
|
||||||
import { NInput } from 'naive-ui';
|
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import SearchBarItem from './SearchBarItem.vue';
|
|
||||||
import type { Tool } from '@/tools/tools.types';
|
|
||||||
import { tools } from '@/tools';
|
|
||||||
import { useTracker } from '@/modules/tracker/tracker.services';
|
|
||||||
import { useFuzzySearch } from '@/composable/fuzzySearch';
|
|
||||||
|
|
||||||
const toolToOption = (tool: Tool) => ({ label: tool.name, value: tool.path, tool });
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const { tracker } = useTracker();
|
|
||||||
|
|
||||||
const queryString = ref('');
|
|
||||||
const inputEl = ref<HTMLElement>();
|
|
||||||
const displayDropDown = ref(true);
|
|
||||||
const isMac = computed(() => window.navigator.userAgent.toLowerCase().includes('mac'));
|
|
||||||
|
|
||||||
const { searchResult } = useFuzzySearch({
|
|
||||||
search: queryString,
|
|
||||||
data: tools,
|
|
||||||
options: { keys: [{ name: 'name', weight: 2 }, 'description', 'keywords'] },
|
|
||||||
});
|
|
||||||
|
|
||||||
const options = computed(() => {
|
|
||||||
if (queryString.value === '') {
|
|
||||||
return tools.map(toolToOption);
|
|
||||||
}
|
|
||||||
|
|
||||||
return searchResult.value.map(toolToOption);
|
|
||||||
});
|
|
||||||
|
|
||||||
const keys = useMagicKeys({
|
|
||||||
passive: false,
|
|
||||||
onEventFired(e) {
|
|
||||||
if (e.ctrlKey && e.key === 'k' && e.type === 'keydown') {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.metaKey && e.key === 'k' && e.type === 'keydown') {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
whenever(keys.ctrl_k, claimFocus);
|
|
||||||
whenever(keys.meta_k, claimFocus);
|
|
||||||
whenever(keys.escape, releaseFocus);
|
|
||||||
|
|
||||||
function renderOption({ tool }: { tool: Tool }) {
|
|
||||||
return h(SearchBarItem, { tool });
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSelect(path: string) {
|
|
||||||
router.push(path);
|
|
||||||
queryString.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function claimFocus() {
|
|
||||||
displayDropDown.value = true;
|
|
||||||
|
|
||||||
inputEl.value?.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function releaseFocus() {
|
|
||||||
displayDropDown.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onFocus() {
|
|
||||||
tracker.trackEvent({ eventName: 'Search-bar focused' });
|
|
||||||
displayDropDown.value = true;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="search-bar">
|
|
||||||
<n-auto-complete
|
|
||||||
v-model:value="queryString"
|
|
||||||
:options="options"
|
|
||||||
:on-select="(value: string | number) => onSelect(String(value))"
|
|
||||||
:render-label="renderOption"
|
|
||||||
default-value="aa"
|
|
||||||
:get-show="() => displayDropDown"
|
|
||||||
:on-focus="onFocus"
|
|
||||||
@update:value="() => (displayDropDown = true)"
|
|
||||||
>
|
|
||||||
<template #default="{ handleInput, handleBlur, handleFocus, value: slotValue }">
|
|
||||||
<NInput
|
|
||||||
ref="inputEl"
|
|
||||||
round
|
|
||||||
clearable
|
|
||||||
:placeholder="`Search a tool (use ${isMac ? 'Cmd' : 'Ctrl'} + K to focus)`"
|
|
||||||
:value="slotValue"
|
|
||||||
:input-props="{ autocomplete: 'disabled' }"
|
|
||||||
@input="handleInput"
|
|
||||||
@focus="handleFocus"
|
|
||||||
@blur="handleBlur"
|
|
||||||
>
|
|
||||||
<template #prefix>
|
|
||||||
<n-icon :component="SearchRound" />
|
|
||||||
</template>
|
|
||||||
</NInput>
|
|
||||||
</template>
|
|
||||||
</n-auto-complete>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
|
@ -1,48 +0,0 @@
|
||||||
<script lang="ts" setup>
|
|
||||||
import type { Tool } from '@/tools/tools.types';
|
|
||||||
|
|
||||||
const props = defineProps<{ tool: Tool }>();
|
|
||||||
const { tool } = toRefs(props);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="search-bar-item">
|
|
||||||
<n-icon class="icon" :component="tool.icon" />
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div class="name">
|
|
||||||
{{ tool.name }}
|
|
||||||
</div>
|
|
||||||
<div class="description">
|
|
||||||
{{ tool.description }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="less" scoped>
|
|
||||||
.search-bar-item {
|
|
||||||
padding: 10px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
font-size: 30px;
|
|
||||||
margin-right: 10px;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.name {
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.description {
|
|
||||||
opacity: 0.7;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -3,7 +3,7 @@ import { NIcon, useThemeVars } from 'naive-ui';
|
||||||
|
|
||||||
import { RouterLink } from 'vue-router';
|
import { RouterLink } from 'vue-router';
|
||||||
import { Heart, Home2, Menu2 } from '@vicons/tabler';
|
import { Heart, Home2, Menu2 } from '@vicons/tabler';
|
||||||
import SearchBar from '../components/SearchBar.vue';
|
|
||||||
import HeroGradient from '../assets/hero-gradient.svg?component';
|
import HeroGradient from '../assets/hero-gradient.svg?component';
|
||||||
import MenuLayout from '../components/MenuLayout.vue';
|
import MenuLayout from '../components/MenuLayout.vue';
|
||||||
import NavbarButtons from '../components/NavbarButtons.vue';
|
import NavbarButtons from '../components/NavbarButtons.vue';
|
||||||
|
@ -104,7 +104,7 @@ const tools = computed<ToolCategory[]>(() => [
|
||||||
Home
|
Home
|
||||||
</n-tooltip>
|
</n-tooltip>
|
||||||
|
|
||||||
<SearchBar />
|
<command-palette mx-2 />
|
||||||
|
|
||||||
<NavbarButtons v-if="!styleStore.isSmallScreen" />
|
<NavbarButtons v-if="!styleStore.isSmallScreen" />
|
||||||
|
|
||||||
|
@ -218,10 +218,6 @@ const tools = computed<ToolCategory[]>(() => [
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
|
||||||
& > *:not(:last-child) {
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-bar {
|
.search-bar {
|
||||||
// width: 100%;
|
// width: 100%;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
|
68
src/modules/command-palette/command-palette.store.ts
Normal file
68
src/modules/command-palette/command-palette.store.ts
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import type { PaletteOption } from './command-palette.types';
|
||||||
|
import { useToolStore } from '@/tools/tools.store';
|
||||||
|
import { useFuzzySearch } from '@/composable/fuzzySearch';
|
||||||
|
import { useStyleStore } from '@/stores/style.store';
|
||||||
|
|
||||||
|
import SunIcon from '~icons/mdi/white-balance-sunny';
|
||||||
|
import GithubIcon from '~icons/mdi/github';
|
||||||
|
import BugIcon from '~icons/mdi/bug-outline';
|
||||||
|
|
||||||
|
export const useCommandPaletteStore = defineStore('command-palette', () => {
|
||||||
|
const toolStore = useToolStore();
|
||||||
|
const styleStore = useStyleStore();
|
||||||
|
const searchPrompt = ref('');
|
||||||
|
|
||||||
|
const toolsOptions = toolStore.tools.map(tool => ({
|
||||||
|
...tool,
|
||||||
|
to: tool.path,
|
||||||
|
toolCategory: tool.category,
|
||||||
|
category: 'Tools',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const searchOptions: PaletteOption[] = [
|
||||||
|
...toolsOptions,
|
||||||
|
{
|
||||||
|
name: 'Toggle dark mode',
|
||||||
|
description: 'Toggle dark mode on or off.',
|
||||||
|
action: () => styleStore.toggleDark(),
|
||||||
|
icon: SunIcon,
|
||||||
|
category: 'Actions',
|
||||||
|
keywords: ['dark', 'theme', 'toggle', 'mode', 'light', 'system'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Github repository',
|
||||||
|
href: 'https://github.com/CorentinTh/it-tools',
|
||||||
|
category: 'External',
|
||||||
|
description: 'View the source code of it-tools on Github.',
|
||||||
|
keywords: ['github', 'repo', 'repository', 'source', 'code'],
|
||||||
|
icon: GithubIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Report a bug or an issue',
|
||||||
|
description: 'Report a bug or an issue to help improve it-tools.',
|
||||||
|
href: 'https://github.com/CorentinTh/it-tools/issues/new/choose',
|
||||||
|
category: 'Actions',
|
||||||
|
keywords: ['report', 'issue', 'bug', 'problem', 'error'],
|
||||||
|
icon: BugIcon,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { searchResult } = useFuzzySearch({
|
||||||
|
search: searchPrompt,
|
||||||
|
data: searchOptions,
|
||||||
|
options: {
|
||||||
|
keys: [{ name: 'name', weight: 2 }, 'description', 'keywords', 'category'],
|
||||||
|
threshold: 0.3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredSearchResult = computed(() =>
|
||||||
|
_.chain(searchResult.value).groupBy('category').mapValues(categoryOptions => _.take(categoryOptions, 5)).value());
|
||||||
|
|
||||||
|
return {
|
||||||
|
filteredSearchResult,
|
||||||
|
searchPrompt,
|
||||||
|
};
|
||||||
|
});
|
13
src/modules/command-palette/command-palette.types.ts
Normal file
13
src/modules/command-palette/command-palette.types.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import type { Component } from 'vue';
|
||||||
|
import type { RouteLocationRaw } from 'vue-router';
|
||||||
|
|
||||||
|
export interface PaletteOption {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
icon?: Component
|
||||||
|
action?: () => void
|
||||||
|
to?: RouteLocationRaw
|
||||||
|
category: string
|
||||||
|
keywords?: string[]
|
||||||
|
href?: string
|
||||||
|
}
|
137
src/modules/command-palette/command-palette.vue
Normal file
137
src/modules/command-palette/command-palette.vue
Normal file
|
@ -0,0 +1,137 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import { useCommandPaletteStore } from './command-palette.store';
|
||||||
|
import type { PaletteOption } from './command-palette.types';
|
||||||
|
|
||||||
|
const isModalOpen = ref(false);
|
||||||
|
const inputRef = ref();
|
||||||
|
const router = useRouter();
|
||||||
|
const isMac = computed(() => window.navigator.userAgent.toLowerCase().includes('mac'));
|
||||||
|
|
||||||
|
const commandPaletteStore = useCommandPaletteStore();
|
||||||
|
const { searchPrompt, filteredSearchResult } = storeToRefs(commandPaletteStore);
|
||||||
|
|
||||||
|
const keys = useMagicKeys({
|
||||||
|
passive: false,
|
||||||
|
onEventFired(e) {
|
||||||
|
if (e.ctrlKey && e.key === 'k' && e.type === 'keydown') {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.metaKey && e.key === 'k' && e.type === 'keydown') {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
whenever(isModalOpen, () => inputRef.value?.focus());
|
||||||
|
|
||||||
|
whenever(keys.ctrl_k, open);
|
||||||
|
whenever(keys.meta_k, open);
|
||||||
|
whenever(keys.escape, close);
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
return isModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
isModalOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedOptionIndex = ref(0);
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
const { key } = event;
|
||||||
|
const isEnterPressed = key === 'Enter';
|
||||||
|
const isArrowUpOrDown = ['ArrowUp', 'ArrowDown'].includes(key);
|
||||||
|
const isArrowDown = key === 'ArrowDown';
|
||||||
|
|
||||||
|
if (isArrowUpOrDown) {
|
||||||
|
const increment = isArrowDown ? 1 : -1;
|
||||||
|
const maxIndex = Math.max(_.chain(filteredSearchResult.value).values().flatten().size().value() - 1, 0);
|
||||||
|
|
||||||
|
selectedOptionIndex.value = Math.min(Math.max(selectedOptionIndex.value + increment, 0), maxIndex);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEnterPressed) {
|
||||||
|
const option = _.chain(filteredSearchResult.value)
|
||||||
|
.values()
|
||||||
|
.flatten()
|
||||||
|
.nth(selectedOptionIndex.value)
|
||||||
|
.value();
|
||||||
|
|
||||||
|
activateOption(option);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOptionIndex(option: PaletteOption) {
|
||||||
|
return _.chain(filteredSearchResult.value)
|
||||||
|
.values()
|
||||||
|
.flatten()
|
||||||
|
.findIndex(o => o === option)
|
||||||
|
.value();
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateOption(option: PaletteOption) {
|
||||||
|
if (option.action) {
|
||||||
|
option.action();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (option.to) {
|
||||||
|
router.push(option.to);
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (option.href) {
|
||||||
|
window.open(option.href, '_blank');
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div flex-1>
|
||||||
|
<c-button w-full important:justify-start @click="isModalOpen = true">
|
||||||
|
<span flex items-center gap-3 op-40>
|
||||||
|
|
||||||
|
<icon-mdi-search />
|
||||||
|
Search...
|
||||||
|
|
||||||
|
<span hidden flex-1 border border-current border-op-40 rounded border-solid px-5px py-3px sm:inline>
|
||||||
|
{{ isMac ? 'Cmd' : 'Ctrl' }} + K
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</c-button>
|
||||||
|
|
||||||
|
<c-modal v-model:open="isModalOpen" class="palette-modal" shadow-xl important:max-w-650px important:pa-12px @keydown="handleKeydown">
|
||||||
|
<c-input-text ref="inputRef" v-model:value="searchPrompt" raw-text placeholder="Type to search a tool or a command..." autofocus clearable />
|
||||||
|
|
||||||
|
<div v-for="(options, category) in filteredSearchResult" :key="category">
|
||||||
|
<div ml-3 mt-3 text-sm font-bold text-primary op-60>
|
||||||
|
{{ category }}
|
||||||
|
</div>
|
||||||
|
<command-palette-option v-for="option in options" :key="option.name" :option="option" :selected="selectedOptionIndex === getOptionIndex(option)" @activated="activateOption" />
|
||||||
|
</div>
|
||||||
|
</c-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.c-input-text {
|
||||||
|
font-size: 18px;
|
||||||
|
|
||||||
|
::v-deep(.input-wrapper) {
|
||||||
|
padding: 4px;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-modal--overlay {
|
||||||
|
align-items: flex-start !important;
|
||||||
|
padding-top: 80px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,36 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { PaletteOption } from '../command-palette.types';
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{ option: PaletteOption; selected?: boolean }>(), {
|
||||||
|
selected: false,
|
||||||
|
});
|
||||||
|
const emit = defineEmits(['activated']);
|
||||||
|
const { option } = toRefs(props);
|
||||||
|
|
||||||
|
const { selected } = toRefs(props);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
role="option"
|
||||||
|
:aria-selected="selected"
|
||||||
|
:class="{
|
||||||
|
'text-white': selected,
|
||||||
|
'bg-primary': selected,
|
||||||
|
}"
|
||||||
|
w-full flex cursor-pointer items-center overflow-hidden rounded pa-3 transition hover:bg-primary hover:text-white
|
||||||
|
@click="() => emit('activated', option)"
|
||||||
|
>
|
||||||
|
<component :is="option.icon" v-if="option.icon" mr-3 h-30px w-30px shrink-0 op-50 />
|
||||||
|
|
||||||
|
<div flex-1 overflow-hidden>
|
||||||
|
<div truncate font-bold lh-tight op-90>
|
||||||
|
{{ option.name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="option.description" truncate lh-tight op-60>
|
||||||
|
{{ option.description }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -46,7 +46,7 @@ const { tracker } = useTracker();
|
||||||
If you need a tool that is currently not present here, and you think can be relevant, you are welcome to submit a
|
If you need a tool that is currently not present here, and you think can be relevant, you are welcome to submit a
|
||||||
feature request in the
|
feature request in the
|
||||||
<c-link
|
<c-link
|
||||||
href="https://github.com/CorentinTh/it-tools/issues/new?assignees=CorentinTh&labels=enhancement&template=feature_request.md&title=%5BFEAT%5D%20My%20feature"
|
href="https://github.com/CorentinTh/it-tools/issues/new/choose"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
|
@ -57,7 +57,7 @@ const { tracker } = useTracker();
|
||||||
<n-p>
|
<n-p>
|
||||||
And if you found a bug, or something broken that doesn't work as expected, please fill a bug report in the
|
And if you found a bug, or something broken that doesn't work as expected, please fill a bug report in the
|
||||||
<c-link
|
<c-link
|
||||||
href="https://github.com/CorentinTh/it-tools/issues/new?assignees=CorentinTh&labels=bug&template=bug_report.md&title=%5BBUG%5D%20My%20bug"
|
href="https://github.com/CorentinTh/it-tools/issues/new/choose"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
|
|
|
@ -29,6 +29,7 @@ const props = withDefaults(
|
||||||
multiline?: boolean
|
multiline?: boolean
|
||||||
rows?: number | string
|
rows?: number | string
|
||||||
autosize?: boolean
|
autosize?: boolean
|
||||||
|
autofocus?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
value: '',
|
value: '',
|
||||||
|
@ -54,13 +55,14 @@ const props = withDefaults(
|
||||||
multiline: false,
|
multiline: false,
|
||||||
rows: 3,
|
rows: 3,
|
||||||
autosize: false,
|
autosize: false,
|
||||||
|
autofocus: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const emit = defineEmits(['update:value']);
|
const emit = defineEmits(['update:value']);
|
||||||
const value = useVModel(props, 'value', emit);
|
const value = useVModel(props, 'value', emit);
|
||||||
const showPassword = ref(false);
|
const showPassword = ref(false);
|
||||||
|
|
||||||
const { id, placeholder, label, validationRules, labelPosition, labelWidth, labelAlign, autosize, readonly, disabled, clearable, type, multiline, rows, rawText } = toRefs(props);
|
const { id, placeholder, label, validationRules, labelPosition, labelWidth, labelAlign, autosize, readonly, disabled, clearable, type, multiline, rows, rawText, autofocus } = toRefs(props);
|
||||||
|
|
||||||
const validation
|
const validation
|
||||||
= props.validation
|
= props.validation
|
||||||
|
@ -74,12 +76,9 @@ const theme = useTheme();
|
||||||
const appTheme = useAppTheme();
|
const appTheme = useAppTheme();
|
||||||
|
|
||||||
const textareaRef = ref<HTMLTextAreaElement>();
|
const textareaRef = ref<HTMLTextAreaElement>();
|
||||||
|
const inputRef = ref<HTMLInputElement>();
|
||||||
const inputWrapperRef = ref<HTMLElement>();
|
const inputWrapperRef = ref<HTMLElement>();
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
inputWrapperRef,
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
value,
|
value,
|
||||||
() => {
|
() => {
|
||||||
|
@ -107,6 +106,38 @@ const htmlInputType = computed(() => {
|
||||||
|
|
||||||
return 'text';
|
return 'text';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function focus() {
|
||||||
|
if (textareaRef.value) {
|
||||||
|
textareaRef.value.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputRef.value) {
|
||||||
|
inputRef.value.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function blur() {
|
||||||
|
if (textareaRef.value) {
|
||||||
|
textareaRef.value.blur?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputRef.value) {
|
||||||
|
inputRef.value.blur?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (autofocus.value) {
|
||||||
|
focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
inputWrapperRef,
|
||||||
|
focus,
|
||||||
|
blur,
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -140,6 +171,7 @@ const htmlInputType = computed(() => {
|
||||||
<input
|
<input
|
||||||
v-else
|
v-else
|
||||||
:id="id"
|
:id="id"
|
||||||
|
ref="inputRef"
|
||||||
v-model="value"
|
v-model="value"
|
||||||
:type="htmlInputType"
|
:type="htmlInputType"
|
||||||
class="input"
|
class="input"
|
||||||
|
|
15
src/ui/c-modal/c-modal.demo.vue
Normal file
15
src/ui/c-modal/c-modal.demo.vue
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
const modal1 = ref();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<c-button @click="() => modal1?.open()">
|
||||||
|
Open Modal
|
||||||
|
</c-button>
|
||||||
|
|
||||||
|
<c-modal ref="modal1">
|
||||||
|
Content
|
||||||
|
</c-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
11
src/ui/c-modal/c-modal.theme.ts
Normal file
11
src/ui/c-modal/c-modal.theme.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import { defineThemes } from '../theme/theme.models';
|
||||||
|
import { appThemes } from '../theme/themes';
|
||||||
|
|
||||||
|
export const { useTheme } = defineThemes({
|
||||||
|
dark: {
|
||||||
|
background: appThemes.dark.background,
|
||||||
|
},
|
||||||
|
light: {
|
||||||
|
background: appThemes.light.background,
|
||||||
|
},
|
||||||
|
});
|
74
src/ui/c-modal/c-modal.vue
Normal file
74
src/ui/c-modal/c-modal.vue
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useTheme } from './c-modal.theme';
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{ open?: boolean; centered?: boolean }>(), {
|
||||||
|
open: false,
|
||||||
|
centered: true,
|
||||||
|
});
|
||||||
|
const emit = defineEmits(['update:open']);
|
||||||
|
const isOpen = useVModel(props, 'open', emit, { passive: true });
|
||||||
|
|
||||||
|
const { centered } = toRefs(props);
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
isOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
isOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
isOpen.value = !isOpen.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
close,
|
||||||
|
open,
|
||||||
|
toggle,
|
||||||
|
isOpen,
|
||||||
|
});
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const theme = useTheme();
|
||||||
|
const modal = ref();
|
||||||
|
|
||||||
|
onClickOutside(modal, () => {
|
||||||
|
if (isOpen.value) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<transition>
|
||||||
|
<div v-if="isOpen" class="c-modal--overlay" fixed left-0 top-0 z-10 h-full w-full flex justify-center px-2 :class="{ 'items-center': centered }">
|
||||||
|
<div ref="modal" class="c-modal--container" v-bind="$attrs" max-w-xl w-full flex-grow rounded-md pa-24px>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.c-modal--overlay {
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-modal--container {
|
||||||
|
background-color: v-bind('theme.background');
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-enter-active,
|
||||||
|
.v-leave-active {
|
||||||
|
transition: opacity 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-enter-from,
|
||||||
|
.v-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -2,6 +2,7 @@ import { defineThemes } from './theme.models';
|
||||||
|
|
||||||
export const { themes: appThemes, useTheme: useAppTheme } = defineThemes({
|
export const { themes: appThemes, useTheme: useAppTheme } = defineThemes({
|
||||||
light: {
|
light: {
|
||||||
|
background: '#ffffff',
|
||||||
text: {
|
text: {
|
||||||
baseColor: '#333639',
|
baseColor: '#333639',
|
||||||
mutedColor: '#767c82',
|
mutedColor: '#767c82',
|
||||||
|
@ -37,6 +38,7 @@ export const { themes: appThemes, useTheme: useAppTheme } = defineThemes({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
|
background: '#1e1e1e',
|
||||||
text: {
|
text: {
|
||||||
baseColor: '#ffffffd1',
|
baseColor: '#ffffffd1',
|
||||||
mutedColor: '#ffffff80',
|
mutedColor: '#ffffff80',
|
||||||
|
|
|
@ -10,5 +10,9 @@ import {
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
presets: [presetUno(), presetAttributify(), presetTypography()],
|
presets: [presetUno(), presetAttributify(), presetTypography()],
|
||||||
transformers: [transformerDirectives(), transformerVariantGroup()],
|
transformers: [transformerDirectives(), transformerVariantGroup()],
|
||||||
safelist: 'prose prose-sm m-auto text-left'.split(' '),
|
theme: {
|
||||||
|
colors: {
|
||||||
|
primary: '#1ea54c',
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue