aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Corentin THOMASSET <corentin.thomasset74@gmail.com> 2023-08-07 17:30:00 +0200
committerGravatar GitHub <noreply@github.com> 2023-08-07 15:30:00 +0000
commitdfa1ba85548508e680f68200ea521be95c3eafe0 (patch)
treec166b635e5eb006806bd40a88252d90735be9ca4
parent6498c9b0fa0427d567506dbd4a6e87d227b138d4 (diff)
downloadit-tools-dfa1ba85548508e680f68200ea521be95c3eafe0.tar.gz
it-tools-dfa1ba85548508e680f68200ea521be95c3eafe0.tar.zst
it-tools-dfa1ba85548508e680f68200ea521be95c3eafe0.zip
feat(ui): added c-select in the ui lib (#550)
* feat(ui): added c-select in the ui lib * refactor(ui): switched n-select to c-select
-rw-r--r--.eslintrc-auto-import.json5
-rw-r--r--components.d.ts9
-rw-r--r--package.json1
-rw-r--r--pnpm-lock.yaml37
-rw-r--r--src/composable/fuzzySearch.ts13
-rw-r--r--src/layouts/base.layout.vue4
-rw-r--r--src/modules/shared/number.models.ts5
-rw-r--r--src/tools/bip39-generator/bip39-generator.vue12
-rw-r--r--src/tools/camera-recorder/camera-recorder.vue35
-rw-r--r--src/tools/date-time-converter/date-time-converter.vue6
-rw-r--r--src/tools/encryption/encryption.vue22
-rw-r--r--src/tools/eta-calculator/eta-calculator.vue16
-rw-r--r--src/tools/hash-text/hash-text.vue46
-rw-r--r--src/tools/hmac-generator/hmac-generator.vue60
-rw-r--r--src/tools/list-converter/list-converter.vue24
-rw-r--r--src/tools/meta-tag-generator/meta-tag-generator.vue8
-rw-r--r--src/tools/mime-types/mime-types.vue32
-rw-r--r--src/tools/phone-parser-and-formatter/phone-parser-and-formatter.vue4
-rw-r--r--src/tools/qr-code-generator/qr-code-generator.vue15
-rw-r--r--src/tools/sql-prettify/sql-prettify.vue80
-rw-r--r--src/ui/c-label/c-label.types.ts7
-rw-r--r--src/ui/c-label/c-label.vue32
-rw-r--r--src/ui/c-select/c-select.demo.vue36
-rw-r--r--src/ui/c-select/c-select.theme.ts60
-rw-r--r--src/ui/c-select/c-select.types.ts4
-rw-r--r--src/ui/c-select/c-select.vue262
-rw-r--r--src/ui/demo/demo-home.page.vue13
-rw-r--r--src/ui/demo/demo.routes.ts10
-rw-r--r--unocss.config.ts7
29 files changed, 666 insertions, 199 deletions
diff --git a/.eslintrc-auto-import.json b/.eslintrc-auto-import.json
index 8830c72..4084d92 100644
--- a/.eslintrc-auto-import.json
+++ b/.eslintrc-auto-import.json
@@ -285,6 +285,7 @@
"watchThrottled": true,
"watchTriggerable": true,
"watchWithFilter": true,
- "whenever": true
+ "whenever": true,
+ "toValue": true
}
-} \ No newline at end of file
+}
diff --git a/components.d.ts b/components.d.ts
index 2514849..64bb5f3 100644
--- a/components.d.ts
+++ b/components.d.ts
@@ -31,6 +31,7 @@ declare module '@vue/runtime-core' {
Chronometer: typeof import('./src/tools/chronometer/chronometer.vue')['default']
CInputText: typeof import('./src/ui/c-input-text/c-input-text.vue')['default']
'CInputText.demo': typeof import('./src/ui/c-input-text/c-input-text.demo.vue')['default']
+ CLabel: typeof import('./src/ui/c-label/c-label.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']
CModal: typeof import('./src/ui/c-modal/c-modal.vue')['default']
@@ -41,7 +42,11 @@ declare module '@vue/runtime-core' {
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']
+ CSelect: typeof import('./src/ui/c-select/c-select.vue')['default']
+ 'CSelect.demo': typeof import('./src/ui/c-select/c-select.demo.vue')['default']
DateTimeConverter: typeof import('./src/tools/date-time-converter/date-time-converter.vue')['default']
+ 'Demo.routes': typeof import('./src/ui/demo/demo.routes.vue')['default']
+ 'DemoHome.page': typeof import('./src/ui/demo/demo-home.page.vue')['default']
DemoWrapper: typeof import('./src/ui/demo/demo-wrapper.vue')['default']
DeviceInformation: typeof import('./src/tools/device-information/device-information.vue')['default']
DiffViewer: typeof import('./src/tools/json-diff/diff-viewer/diff-viewer.vue')['default']
@@ -60,9 +65,13 @@ declare module '@vue/runtime-core' {
HtmlEntities: typeof import('./src/tools/html-entities/html-entities.vue')['default']
HtmlWysiwygEditor: typeof import('./src/tools/html-wysiwyg-editor/html-wysiwyg-editor.vue')['default']
HttpStatusCodes: typeof import('./src/tools/http-status-codes/http-status-codes.vue')['default']
+ 'IconMdi:brushVariant': typeof import('~icons/mdi/brush-variant')['default']
'IconMdi:kettleSteamOutline': typeof import('~icons/mdi/kettle-steam-outline')['default']
IconMdiArrowRightBottom: typeof import('~icons/mdi/arrow-right-bottom')['default']
IconMdiCamera: typeof import('~icons/mdi/camera')['default']
+ IconMdiCameraOutline: typeof import('~icons/mdi/camera-outline')['default']
+ IconMdiCameraVideoOff: typeof import('~icons/mdi/camera-video-off')['default']
+ IconMdiChevronDown: typeof import('~icons/mdi/chevron-down')['default']
IconMdiChevronRight: typeof import('~icons/mdi/chevron-right')['default']
IconMdiClose: typeof import('~icons/mdi/close')['default']
IconMdiContentCopy: typeof import('~icons/mdi/content-copy')['default']
diff --git a/package.json b/package.json
index 5e98d5d..f4b95ce 100644
--- a/package.json
+++ b/package.json
@@ -118,6 +118,7 @@
"prettier": "^2.8.7",
"typescript": "~4.9.0",
"unocss": "^0.53.0",
+ "unocss-preset-scrollbar": "^0.2.1",
"unplugin-icons": "^0.16.1",
"unplugin-vue-components": "^0.25.0",
"vite": "^4.0.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 684b2e8..836e4a2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -256,6 +256,9 @@ devDependencies:
unocss:
specifier: ^0.53.0
version: 0.53.0(postcss@8.4.24)(rollup@2.79.1)(vite@4.3.9)
+ unocss-preset-scrollbar:
+ specifier: ^0.2.1
+ version: 0.2.1(unocss@0.53.0)
unplugin-icons:
specifier: ^0.16.1
version: 0.16.1(@vue/compiler-sfc@3.2.47)
@@ -3366,7 +3369,7 @@ packages:
dependencies:
'@unhead/dom': 0.5.1
'@unhead/schema': 0.5.1
- '@vueuse/shared': 10.2.1(vue@3.3.4)
+ '@vueuse/shared': 10.3.0(vue@3.3.4)
unhead: 0.5.1
vue: 3.3.4
transitivePeerDependencies:
@@ -3413,6 +3416,10 @@ packages:
unconfig: 0.3.9
dev: true
+ /@unocss/core@0.31.17:
+ resolution: {integrity: sha512-DJ3Uk2ePVXvV1qQmgoLK44aqB6f0s+naOEvouI97nzVXDZgxDQPBxIPB/L4vvE4U+gQxEiHwwE3gJ75iPqVzXw==}
+ dev: true
+
/@unocss/core@0.53.0:
resolution: {integrity: sha512-MB6hqSN2wjmm3NNYspNqzxvMv7LnyLqz0uCWr15elRqnjsuq01w7DZ1iPS9ckA2M3YjQIRTXR9YPtDbSqY0jcA==}
dev: true
@@ -3486,6 +3493,12 @@ packages:
- supports-color
dev: true
+ /@unocss/preset-mini@0.31.17:
+ resolution: {integrity: sha512-gVgMTOKLt3O1ym348QIBmR5sS9W0Ozkk5xelhH6e0VXcpg0dXDPDrl4hFErMy4x6IB86yyJG6Dz5JhcwQB13Ig==}
+ dependencies:
+ '@unocss/core': 0.31.17
+ dev: true
+
/@unocss/preset-mini@0.53.0:
resolution: {integrity: sha512-hGj9ltZUJIuPT+9bO+R0OlsQOSlV7rjQRkSSMnUaDsuKfzhahsyc7QglNHZI4wuTI/9iSJKGUD4nvTe559+8Hg==}
dependencies:
@@ -3928,8 +3941,8 @@ packages:
- vue
dev: false
- /@vueuse/shared@10.2.1(vue@3.3.4):
- resolution: {integrity: sha512-QWHq2bSuGptkcxx4f4M/fBYC3Y8d3M2UYyLsyzoPgEoVzJURQ0oJeWXu79OiLlBb8gTKkqe4mO85T/sf39mmiw==}
+ /@vueuse/shared@10.3.0(vue@3.3.4):
+ resolution: {integrity: sha512-kGqCTEuFPMK4+fNWy6dUOiYmxGcUbtznMwBZLC1PubidF4VZY05B+Oht7Jh7/6x4VOWGpvu3R37WHi81cKpiqg==}
dependencies:
vue-demi: 0.14.5(vue@3.3.4)
transitivePeerDependencies:
@@ -5013,7 +5026,6 @@ packages:
/esbuild@0.17.19:
resolution: {integrity: sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==}
engines: {node: '>=12'}
- hasBin: true
requiresBuild: true
optionalDependencies:
'@esbuild/android-arm': 0.17.19
@@ -7772,7 +7784,6 @@ packages:
/rollup@3.25.1:
resolution: {integrity: sha512-tywOR+rwIt5m2ZAWSe5AIJcTat8vGlnPFAv15ycCrw33t6iFsXZ6mzHVFh2psSjxQPmI+xgzMZZizUAukBI4aQ==}
engines: {node: '>=14.18.0', npm: '>=8.0.0'}
- hasBin: true
optionalDependencies:
fsevents: 2.3.2
dev: true
@@ -8502,6 +8513,15 @@ packages:
engines: {node: '>= 10.0.0'}
dev: true
+ /unocss-preset-scrollbar@0.2.1(unocss@0.53.0):
+ resolution: {integrity: sha512-7ubHdOaUwr7xBn1glPpICNNsM2SZGjvWK5uRPNiQYsrZ9YFjsCGHk9x5S2R8pTkuMDQeiaSa/UQbYhjC8Fra5g==}
+ peerDependencies:
+ unocss: '>= 0.31.13 < 1'
+ dependencies:
+ '@unocss/preset-mini': 0.31.17
+ unocss: 0.53.0(postcss@8.4.24)(rollup@2.79.1)(vite@4.3.9)
+ dev: true
+
/unocss@0.53.0(postcss@8.4.24)(rollup@2.79.1)(vite@4.3.9):
resolution: {integrity: sha512-kY4h5ERiDYlSnL2X+hbDfh+uaF7QNouy7j51GOTUr3Q0aaWehaNd05b15SjHrab559dEC0mYfrSEdh/DnCK1cw==}
engines: {node: '>=14'}
@@ -8635,7 +8655,6 @@ packages:
/update-browserslist-db@1.0.10(browserslist@4.21.5):
resolution: {integrity: sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ==}
- hasBin: true
peerDependencies:
browserslist: '>= 4.21.0'
dependencies:
@@ -8646,7 +8665,6 @@ packages:
/update-browserslist-db@1.0.11(browserslist@4.21.9):
resolution: {integrity: sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==}
- hasBin: true
peerDependencies:
browserslist: '>= 4.21.0'
dependencies:
@@ -8777,7 +8795,6 @@ packages:
/vite@4.3.9(@types/node@18.15.11)(less@4.1.3):
resolution: {integrity: sha512-qsTNZjO9NoJNW7KnOrgYwczm0WctJ8m/yqYAMAK9Lxt4SoySUfS5S8ia9K7JHpa3KEeMfyF8LoJ3c5NeBJy6pg==}
engines: {node: ^14.18.0 || >=16.0.0}
- hasBin: true
peerDependencies:
'@types/node': '>= 14'
less: '*'
@@ -8811,7 +8828,6 @@ packages:
/vitest@0.32.0(jsdom@19.0.0)(less@4.1.3):
resolution: {integrity: sha512-SW83o629gCqnV3BqBnTxhB10DAwzwEx3z+rqYZESehUB+eWsJxwcBQx7CKy0otuGMJTYh7qCVuUX23HkftGl/Q==}
engines: {node: '>=v14.18.0'}
- hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@vitest/browser': '*'
@@ -8886,7 +8902,6 @@ packages:
/vue-demi@0.13.11(vue@3.3.4):
resolution: {integrity: sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==}
engines: {node: '>=12'}
- hasBin: true
requiresBuild: true
peerDependencies:
'@vue/composition-api': ^1.0.0-rc.1
@@ -8901,7 +8916,6 @@ packages:
/vue-demi@0.14.1(vue@3.3.4):
resolution: {integrity: sha512-rt+yuCtXvscYot9SQQj3WKZJVSriPNqVkpVBNEHPzSgBv7QIYzsS410VqVgvx8f9AAPgjg+XPKvmV3vOqqkJQQ==}
engines: {node: '>=12'}
- hasBin: true
requiresBuild: true
peerDependencies:
'@vue/composition-api': ^1.0.0-rc.1
@@ -8976,7 +8990,6 @@ packages:
/vue-tsc@1.8.1(typescript@4.9.3):
resolution: {integrity: sha512-GxBQrcb0Qvyrj1uZqnTXQyWbXdNDRY2MTa+r7ESgjhf+WzBSdxZfkS3KD/C3WhKYG+aN8hf44Hp5Gqzb6PehAA==}
- hasBin: true
peerDependencies:
typescript: '*'
dependencies:
diff --git a/src/composable/fuzzySearch.ts b/src/composable/fuzzySearch.ts
index 66480f7..00794fd 100644
--- a/src/composable/fuzzySearch.ts
+++ b/src/composable/fuzzySearch.ts
@@ -11,12 +11,19 @@ function useFuzzySearch<Data>({
}: {
search: MaybeRef<string>
data: Data[]
- options?: Fuse.IFuseOptions<Data>
+ options?: Fuse.IFuseOptions<Data> & { filterEmpty?: boolean }
}) {
const fuse = new Fuse(data, options);
+ const filterEmpty = options.filterEmpty ?? true;
- const searchResult = computed(() => {
- return fuse.search(get(search)).map(({ item }) => item);
+ const searchResult = computed<Data[]>(() => {
+ const query = get(search);
+
+ if (!filterEmpty && query === '') {
+ return data;
+ }
+
+ return fuse.search(query).map(({ item }) => item);
});
return { searchResult };
diff --git a/src/layouts/base.layout.vue b/src/layouts/base.layout.vue
index 5ee8a9d..5c3841b 100644
--- a/src/layouts/base.layout.vue
+++ b/src/layouts/base.layout.vue
@@ -103,6 +103,10 @@ const tools = computed<ToolCategory[]>(() => [
Home
</n-tooltip>
+ <c-button to="/c-lib" circle variant="text" aria-label="UI Lib">
+ <icon-mdi:brush-variant text-20px />
+ </c-button>
+
<command-palette />
<div>
diff --git a/src/modules/shared/number.models.ts b/src/modules/shared/number.models.ts
new file mode 100644
index 0000000..45f7509
--- /dev/null
+++ b/src/modules/shared/number.models.ts
@@ -0,0 +1,5 @@
+function clamp({ value, min = 0, max = 100 }: { value: number; min?: number; max?: number }) {
+ return Math.min(Math.max(value, min), max);
+}
+
+export { clamp };
diff --git a/src/tools/bip39-generator/bip39-generator.vue b/src/tools/bip39-generator/bip39-generator.vue
index 6c2af7b..03c1fe3 100644
--- a/src/tools/bip39-generator/bip39-generator.vue
+++ b/src/tools/bip39-generator/bip39-generator.vue
@@ -84,12 +84,12 @@ const { copy: copyPassphrase } = useCopy({ source: passphrase, text: 'Passphrase
<div>
<n-grid cols="3" x-gap="12">
<n-gi span="1">
- <n-form-item label="Language:">
- <n-select
- v-model:value="language"
- :options="Object.keys(languages).map((label) => ({ label, value: label }))"
- />
- </n-form-item>
+ <c-select
+ v-model:value="language"
+ searchable
+ label="Language:"
+ :options="Object.keys(languages)"
+ />
</n-gi>
<n-gi span="2">
<n-form-item
diff --git a/src/tools/camera-recorder/camera-recorder.vue b/src/tools/camera-recorder/camera-recorder.vue
index 19fe30b..34ce39a 100644
--- a/src/tools/camera-recorder/camera-recorder.vue
+++ b/src/tools/camera-recorder/camera-recorder.vue
@@ -122,23 +122,24 @@ function downloadMedia({ type, value, createdAt }: Media) {
</c-card>
<c-card v-else>
- <div flex gap-2>
- <div flex-1>
- <div>Video</div>
- <n-select
- v-model:value="currentCamera"
- :options="cameras.map(({ deviceId, label }) => ({ value: deviceId, label }))"
- placeholder="Select camera"
- />
- </div>
- <div v-if="currentMicrophone && microphones.length > 0" flex-1>
- <div>Audio</div>
- <n-select
- v-model:value="currentMicrophone"
- :options="microphones.map(({ deviceId, label }) => ({ value: deviceId, label }))"
- placeholder="Select microphone"
- />
- </div>
+ <div flex flex-col gap-2>
+ <c-select
+ v-model:value="currentCamera"
+ label-position="left"
+ label-width="60px"
+ label="Video:"
+ :options="cameras.map(({ deviceId, label }) => ({ value: deviceId, label }))"
+ placeholder="Select camera"
+ />
+ <c-select
+ v-if="currentMicrophone && microphones.length > 0"
+ v-model:value="currentMicrophone"
+ label="Audio:"
+ label-position="left"
+ label-width="60px"
+ :options="microphones.map(({ deviceId, label }) => ({ value: deviceId, label }))"
+ placeholder="Select microphone"
+ />
</div>
<div v-if="!isMediaStreamAvailable" mt-3 flex justify-center>
diff --git a/src/tools/date-time-converter/date-time-converter.vue b/src/tools/date-time-converter/date-time-converter.vue
index fcebce8..79f9218 100644
--- a/src/tools/date-time-converter/date-time-converter.vue
+++ b/src/tools/date-time-converter/date-time-converter.vue
@@ -142,7 +142,7 @@ function formatDateUsingFormatter(formatter: (date: Date) => string, date?: Date
<template>
<div>
- <n-input-group>
+ <div flex gap-2>
<c-input-text
v-model:value="inputDate"
autofocus
@@ -153,13 +153,13 @@ function formatDateUsingFormatter(formatter: (date: Date) => string, date?: Date
@update:value="onDateInputChanged"
/>
- <n-select
+ <c-select
v-model:value="formatIndex"
style="flex: 0 0 170px"
:options="formats.map(({ name }, i) => ({ label: name, value: i }))"
data-test-id="date-time-converter-format-select"
/>
- </n-input-group>
+ </div>
<n-divider />
diff --git a/src/tools/encryption/encryption.vue b/src/tools/encryption/encryption.vue
index a11a307..4a348f8 100644
--- a/src/tools/encryption/encryption.vue
+++ b/src/tools/encryption/encryption.vue
@@ -29,12 +29,11 @@ const decryptOutput = computed(() =>
<div flex flex-1 flex-col gap-2>
<c-input-text v-model:value="cypherSecret" label="Your secret key:" clearable raw-text />
- <n-form-item label="Encryption algorithm:" :show-feedback="false">
- <n-select
- v-model:value="cypherAlgo"
- :options="Object.keys(algos).map((label) => ({ label, value: label }))"
- />
- </n-form-item>
+ <c-select
+ v-model:value="cypherAlgo"
+ label="Encryption algorithm:"
+ :options="Object.keys(algos).map((label) => ({ label, value: label }))"
+ />
</div>
</div>
<c-input-text
@@ -57,12 +56,11 @@ const decryptOutput = computed(() =>
<div flex flex-1 flex-col gap-2>
<c-input-text v-model:value="decryptSecret" label="Your secret key:" clearable raw-text />
- <n-form-item label="Encryption algorithm:" :show-feedback="false">
- <n-select
- v-model:value="decryptAlgo"
- :options="Object.keys(algos).map((label) => ({ label, value: label }))"
- />
- </n-form-item>
+ <c-select
+ v-model:value="decryptAlgo"
+ label="Encryption algorithm:"
+ :options="Object.keys(algos).map((label) => ({ label, value: label }))"
+ />
</div>
</div>
<c-input-text
diff --git a/src/tools/eta-calculator/eta-calculator.vue b/src/tools/eta-calculator/eta-calculator.vue
index 8a45fc5..a81a77d 100644
--- a/src/tools/eta-calculator/eta-calculator.vue
+++ b/src/tools/eta-calculator/eta-calculator.vue
@@ -39,13 +39,15 @@ const endAt = computed(() =>
</n-form-item>
</div>
- <n-form-item label="Amount of unit consumed by time span" :show-feedback="false">
+ <p>Amount of unit consumed by time span</p>
+ <div flex flex-col items-baseline gap-y-2 md:flex-row>
<n-input-number v-model:value="unitPerTimeSpan" :min="1" />
- <span mx-3>in</span>
- <n-input-group>
- <n-input-number v-model:value="timeSpan" :min="1" />
- <n-select
+ <div flex items-baseline gap-2>
+ <span ml-2>in</span>
+ <n-input-number v-model:value="timeSpan" min-w-130px :min="1" />
+ <c-select
v-model:value="timeSpanUnitMultiplier"
+ min-w-130px
:options="[
{ label: 'milliseconds', value: 1 },
{ label: 'seconds', value: 1000 },
@@ -54,8 +56,8 @@ const endAt = computed(() =>
{ label: 'days', value: 1000 * 60 * 60 * 24 },
]"
/>
- </n-input-group>
- </n-form-item>
+ </div>
+ </div>
<n-divider />
<c-card mb-2>
diff --git a/src/tools/hash-text/hash-text.vue b/src/tools/hash-text/hash-text.vue
index d367407..6eae981 100644
--- a/src/tools/hash-text/hash-text.vue
+++ b/src/tools/hash-text/hash-text.vue
@@ -41,29 +41,29 @@ const hashText = (algo: AlgoNames, value: string) => formatWithEncoding(algos[al
<n-divider />
- <n-form-item label="Digest encoding">
- <n-select
- v-model:value="encoding"
- :options="[
- {
- label: 'Binary (base 2)',
- value: 'Bin',
- },
- {
- label: 'Hexadecimal (base 16)',
- value: 'Hex',
- },
- {
- label: 'Base64 (base 64)',
- value: 'Base64',
- },
- {
- label: 'Base64url (base 64 with url safe chars)',
- value: 'Base64url',
- },
- ]"
- />
- </n-form-item>
+ <c-select
+ v-model:value="encoding"
+ mb-4
+ label="Digest encoding"
+ :options="[
+ {
+ label: 'Binary (base 2)',
+ value: 'Bin',
+ },
+ {
+ label: 'Hexadecimal (base 16)',
+ value: 'Hex',
+ },
+ {
+ label: 'Base64 (base 64)',
+ value: 'Base64',
+ },
+ {
+ label: 'Base64url (base 64 with url safe chars)',
+ value: 'Base64url',
+ },
+ ]"
+ />
<div v-for="algo in algoNames" :key="algo" style="margin: 5px 0">
<n-input-group>
diff --git a/src/tools/hmac-generator/hmac-generator.vue b/src/tools/hmac-generator/hmac-generator.vue
index 463e27b..fda3988 100644
--- a/src/tools/hmac-generator/hmac-generator.vue
+++ b/src/tools/hmac-generator/hmac-generator.vue
@@ -51,37 +51,35 @@ const { copy } = useCopy({ source: hmac });
<c-input-text v-model:value="secret" raw-text placeholder="Enter the secret key..." label="Secret key" clearable />
<div flex gap-2>
- <n-form-item label="Hashing function" flex-1>
- <n-select
- v-model:value="hashFunction"
- placeholder="Select an hashing function..."
- :options="Object.keys(algos).map((label) => ({ label, value: label }))"
- />
- </n-form-item>
- <n-form-item label="Output encoding" flex-1>
- <n-select
- v-model:value="encoding"
- placeholder="Select the result encoding..."
- :options="[
- {
- label: 'Binary (base 2)',
- value: 'Bin',
- },
- {
- label: 'Hexadecimal (base 16)',
- value: 'Hex',
- },
- {
- label: 'Base64 (base 64)',
- value: 'Base64',
- },
- {
- label: 'Base64-url (base 64 with url safe chars)',
- value: 'Base64url',
- },
- ]"
- />
- </n-form-item>
+ <c-select
+ v-model:value="hashFunction" label="Hashing function"
+ flex-1
+ placeholder="Select an hashing function..."
+ :options="Object.keys(algos).map((label) => ({ label, value: label }))"
+ />
+ <c-select
+ v-model:value="encoding" label="Output encoding"
+ flex-1
+ placeholder="Select the result encoding..."
+ :options="[
+ {
+ label: 'Binary (base 2)',
+ value: 'Bin',
+ },
+ {
+ label: 'Hexadecimal (base 16)',
+ value: 'Hex',
+ },
+ {
+ label: 'Base64 (base 64)',
+ value: 'Base64',
+ },
+ {
+ label: 'Base64-url (base 64 with url safe chars)',
+ value: 'Base64url',
+ },
+ ]"
+ />
</div>
<input-copyable v-model:value="hmac" type="textarea" placeholder="The result of the HMAC..." label="HMAC of your text" />
<div flex justify-center>
diff --git a/src/tools/list-converter/list-converter.vue b/src/tools/list-converter/list-converter.vue
index 2e6d2b3..19dd30e 100644
--- a/src/tools/list-converter/list-converter.vue
+++ b/src/tools/list-converter/list-converter.vue
@@ -61,17 +61,19 @@ function transformer(value: string) {
</n-form-item>
</div>
<div flex-1>
- <n-form-item label="Sort list" label-placement="left" label-width="120" :show-feedback="false" mb-2>
- <n-select
- v-model:value="conversionConfig.sortList"
- :options="sortOrderOptions"
- clearable
- w-full
- :disabled="conversionConfig.reverseList"
- data-test-id="sortList"
- placeholder="Sort alphabetically"
- />
- </n-form-item>
+ <c-select
+ v-model:value="conversionConfig.sortList"
+ label="Sort list"
+ label-position="left"
+ label-width="120px"
+ label-align="right"
+ mb-2
+ :options="sortOrderOptions"
+ w-full
+ :disabled="conversionConfig.reverseList"
+ data-test-id="sortList"
+ placeholder="Sort alphabetically"
+ />
<c-input-text
v-model:value="conversionConfig.separator"
diff --git a/src/tools/meta-tag-generator/meta-tag-generator.vue b/src/tools/meta-tag-generator/meta-tag-generator.vue
index de8a0c6..aafa779 100644
--- a/src/tools/meta-tag-generator/meta-tag-generator.vue
+++ b/src/tools/meta-tag-generator/meta-tag-generator.vue
@@ -53,12 +53,15 @@ const metaTags = computed(() => {
<template>
<div>
<div v-for="{ name, elements } of sections" :key="name" style="margin-bottom: 15px">
- <n-form-item :label="name" :show-feedback="false" />
+ <div mb-5px>
+ {{ name }}
+ </div>
<n-input-group v-for="{ key, type, label, placeholder, ...element } of elements" :key="key">
<n-input-group-label style="flex: 0 0 110px">
{{ label }}
</n-input-group-label>
+
<c-input-text v-if="type === 'input'" v-model:value="metadata[key]" :placeholder="placeholder" clearable />
<n-dynamic-input
v-else-if="type === 'input-multiple'"
@@ -69,9 +72,10 @@ const metaTags = computed(() => {
:show-sort-button="true"
/>
- <n-select
+ <c-select
v-else-if="type === 'select'"
v-model:value="metadata[key]"
+ w-full
:placeholder="placeholder"
:options="(element as OGSchemaTypeElementSelect).options"
/>
diff --git a/src/tools/mime-types/mime-types.vue b/src/tools/mime-types/mime-types.vue
index 80eba60..3bd7611 100644
--- a/src/tools/mime-types/mime-types.vue
+++ b/src/tools/mime-types/mime-types.vue
@@ -26,15 +26,13 @@ const mimeTypeFound = computed(() => (selectedExtension.value ? extensionToMimeT
<div style="opacity: 0.8">
Know which file extensions are associated to a mime-type
</div>
- <n-form-item>
- <n-select
- v-model:value="selectedMimeType"
- filterable
- :options="mimeToExtensionsOptions"
- size="large"
- placeholder="Select your mimetype here... (ex: application/pdf)"
- />
- </n-form-item>
+ <c-select
+ v-model:value="selectedMimeType"
+ searchable
+ my-4
+ :options="mimeToExtensionsOptions"
+ placeholder="Select your mimetype here... (ex: application/pdf)"
+ />
<div v-if="extensionsFound.length > 0">
Extensions of files with the <n-tag round :bordered="false">
@@ -62,15 +60,13 @@ const mimeTypeFound = computed(() => (selectedExtension.value ? extensionToMimeT
<div style="opacity: 0.8">
Know which mime type is associated to a file extension
</div>
- <n-form-item>
- <n-select
- v-model:value="selectedExtension"
- filterable
- :options="extensionToMimeTypeOptions"
- size="large"
- placeholder="Select your mimetype here... (ex: application/pdf)"
- />
- </n-form-item>
+ <c-select
+ v-model:value="selectedExtension"
+ searchable
+ my-4
+ :options="extensionToMimeTypeOptions"
+ placeholder="Select your mimetype here... (ex: application/pdf)"
+ />
<div v-if="selectedExtension">
Mime type associated to the extension <n-tag round :bordered="false">
diff --git a/src/tools/phone-parser-and-formatter/phone-parser-and-formatter.vue b/src/tools/phone-parser-and-formatter/phone-parser-and-formatter.vue
index fde65f4..5bc6778 100644
--- a/src/tools/phone-parser-and-formatter/phone-parser-and-formatter.vue
+++ b/src/tools/phone-parser-and-formatter/phone-parser-and-formatter.vue
@@ -85,9 +85,7 @@ const countriesOptions = getCountries().map(code => ({
<template>
<div>
- <n-form-item label="Default country code:">
- <n-select v-model:value="defaultCountryCode" :options="countriesOptions" filterable />
- </n-form-item>
+ <c-select v-model:value="defaultCountryCode" label="Default country code:" :options="countriesOptions" searchable mb-5 />
<c-input-text
v-model:value="rawPhone"
diff --git a/src/tools/qr-code-generator/qr-code-generator.vue b/src/tools/qr-code-generator/qr-code-generator.vue
index f0c6ac2..8bc9f74 100644
--- a/src/tools/qr-code-generator/qr-code-generator.vue
+++ b/src/tools/qr-code-generator/qr-code-generator.vue
@@ -35,6 +35,7 @@ const { download } = useDownloadFileFromBase64({ source: qrcode, filename: 'qr-c
label="Text:"
multiline
rows="1"
+ autosize
placeholder="Your link or text..."
mb-6
/>
@@ -45,12 +46,14 @@ const { download } = useDownloadFileFromBase64({ source: qrcode, filename: 'qr-c
<n-form-item label="Background color:">
<n-color-picker v-model:value="background" :modes="['hex']" />
</n-form-item>
- <n-form-item label="Error resistance:">
- <n-select
- v-model:value="errorCorrectionLevel"
- :options="errorCorrectionLevels.map((value) => ({ label: value, value }))"
- />
- </n-form-item>
+ <c-select
+ v-model:value="errorCorrectionLevel"
+ label="Error resistance:"
+ label-position="left"
+ label-width="130px"
+ label-align="right"
+ :options="errorCorrectionLevels.map((value) => ({ label: value, value }))"
+ />
</n-form>
</n-gi>
<n-gi>
diff --git a/src/tools/sql-prettify/sql-prettify.vue b/src/tools/sql-prettify/sql-prettify.vue
index a0da8a7..049d6c8 100644
--- a/src/tools/sql-prettify/sql-prettify.vue
+++ b/src/tools/sql-prettify/sql-prettify.vue
@@ -19,47 +19,45 @@ const prettySQL = computed(() => formatSQL(rawSQL.value, config));
<template>
<div style="flex: 0 0 100%">
- <div mx-auto style="max-width: 600px" flex gap-2 :class="{ 'flex-col': styleStore.isSmallScreen }">
- <n-form-item label="Dialect" label-width="500" flex-1>
- <n-select
- v-model:value="config.language"
- :options="[
- { label: 'GCP BigQuery', value: 'bigquery' },
- { label: 'IBM DB2', value: 'db2' },
- { label: 'Apache Hive', value: 'hive' },
- { label: 'MariaDB', value: 'mariadb' },
- { label: 'MySQL', value: 'mysql' },
- { label: 'Couchbase N1QL', value: 'n1ql' },
- { label: 'Oracle PL/SQL', value: 'plsql' },
- { label: 'PostgreSQL', value: 'postgresql' },
- { label: 'Amazon Redshift', value: 'redshift' },
- { label: 'Spark', value: 'spark' },
- { label: 'Standard SQL', value: 'sql' },
- { label: 'sqlite', value: 'sqlite' },
- { label: 'SQL Server Transact-SQL', value: 'tsql' },
- ]"
- />
- </n-form-item>
- <n-form-item label="Keyword case" flex-1>
- <n-select
- v-model:value="config.keywordCase"
- :options="[
- { label: 'UPPERCASE', value: 'upper' },
- { label: 'lowercase', value: 'lower' },
- { label: 'Preserve', value: 'preserve' },
- ]"
- />
- </n-form-item>
- <n-form-item label="Indent style" flex-1>
- <n-select
- v-model:value="config.indentStyle"
- :options="[
- { label: 'Standard', value: 'standard' },
- { label: 'Tabular left', value: 'tabularLeft' },
- { label: 'Tabular right', value: 'tabularRight' },
- ]"
- />
- </n-form-item>
+ <div style="max-width: 600px" :class="{ 'flex-col': styleStore.isSmallScreen }" mx-auto mb-5 flex gap-2>
+ <c-select
+ v-model:value="config.language"
+ flex-1
+ label="Dialect"
+ :options="[
+ { label: 'GCP BigQuery', value: 'bigquery' },
+ { label: 'IBM DB2', value: 'db2' },
+ { label: 'Apache Hive', value: 'hive' },
+ { label: 'MariaDB', value: 'mariadb' },
+ { label: 'MySQL', value: 'mysql' },
+ { label: 'Couchbase N1QL', value: 'n1ql' },
+ { label: 'Oracle PL/SQL', value: 'plsql' },
+ { label: 'PostgreSQL', value: 'postgresql' },
+ { label: 'Amazon Redshift', value: 'redshift' },
+ { label: 'Spark', value: 'spark' },
+ { label: 'Standard SQL', value: 'sql' },
+ { label: 'sqlite', value: 'sqlite' },
+ { label: 'SQL Server Transact-SQL', value: 'tsql' },
+ ]"
+ />
+ <c-select
+ v-model:value="config.keywordCase" label="Keyword case"
+ flex-1
+ :options="[
+ { label: 'UPPERCASE', value: 'upper' },
+ { label: 'lowercase', value: 'lower' },
+ { label: 'Preserve', value: 'preserve' },
+ ]"
+ />
+ <c-select
+ v-model:value="config.indentStyle" label="Indent style"
+ flex-1
+ :options="[
+ { label: 'Standard', value: 'standard' },
+ { label: 'Tabular left', value: 'tabularLeft' },
+ { label: 'Tabular right', value: 'tabularRight' },
+ ]"
+ />
</div>
</div>
diff --git a/src/ui/c-label/c-label.types.ts b/src/ui/c-label/c-label.types.ts
new file mode 100644
index 0000000..f82ba0f
--- /dev/null
+++ b/src/ui/c-label/c-label.types.ts
@@ -0,0 +1,7 @@
+export interface CLabelProps {
+ label?: string
+ labelFor?: string
+ labelPosition?: 'top' | 'left'
+ labelWidth?: string
+ labelAlign?: 'left' | 'right' | 'center'
+}
diff --git a/src/ui/c-label/c-label.vue b/src/ui/c-label/c-label.vue
new file mode 100644
index 0000000..3e0f64d
--- /dev/null
+++ b/src/ui/c-label/c-label.vue
@@ -0,0 +1,32 @@
+<script lang="ts" setup>
+import { toRefs } from 'vue';
+import type { CLabelProps } from './c-label.types';
+
+const props = withDefaults(defineProps<CLabelProps>(), { label: undefined, labelAlign: 'left', labelFor: undefined, labelPosition: 'top', labelWidth: 'auto' });
+const { label, labelAlign, labelFor, labelPosition, labelWidth } = toRefs(props);
+</script>
+
+<template>
+ <div
+ :class="{
+ 'flex-col': labelPosition === 'top',
+ 'flex-row': labelPosition === 'left',
+ }"
+ flex
+ items-baseline
+ >
+ <label
+ v-if="label" :for="labelFor" :style="{ flex: `0 0 ${labelWidth}` }"
+ mb-5px
+ pr-12px
+ :class="{
+ 'text-left': labelAlign === 'left',
+ 'text-center': labelAlign === 'center',
+ 'text-right': labelAlign === 'right',
+ }"
+ >
+ {{ label }}
+ </label>
+ <slot />
+ </div>
+</template>
diff --git a/src/ui/c-select/c-select.demo.vue b/src/ui/c-select/c-select.demo.vue
new file mode 100644
index 0000000..ae553bb
--- /dev/null
+++ b/src/ui/c-select/c-select.demo.vue
@@ -0,0 +1,36 @@
+<script lang="ts" setup>
+const optionsA = [
+ { label: 'Option A', value: 'a' },
+ { label: 'Option B', value: 'b' },
+ { label: 'Option C', value: 'c' },
+];
+
+const optionsBig = Array.from({ length: 1000 }, (_, i) => ({ label: `Option ${i}`, value: i }));
+
+const sizes = ['small', 'medium', 'large'] as const;
+const value = ref('');
+</script>
+
+<template>
+ <h2>Sizes</h2>
+ <c-select v-for="size in sizes" :key="size" v-model:value="value" :options="optionsA" :size="size" mb-2 />
+
+ <h2>Searchable</h2>
+ <c-select v-for="size in sizes" :key="size" v-model:value="value" :options="optionsA" :size="size" searchable mb-2 />
+
+ <h2>Big list</h2>
+ <c-select v-model:value="value" :options="optionsBig" searchable />
+
+ <h2>Empty</h2>
+ <c-select :options="[]" />
+
+ <h2>String array as options</h2>
+ <c-select v-model:value="value" :options="['a', 'Option B', 'Option C']" />
+
+ <h2>Labels</h2>
+ <c-select label="Label" mb-2 />
+ <c-select label="Label" label-position="left" mb-2 />
+ <c-select label="Label" label-position="left" label-align="left" mb-2 label-width="200px" />
+ <c-select label="Label" label-position="left" label-align="center" mb-2 label-width="200px" />
+ <c-select label="Label" label-position="left" label-align="right" mb-2 label-width="200px" />
+</template>
diff --git a/src/ui/c-select/c-select.theme.ts b/src/ui/c-select/c-select.theme.ts
new file mode 100644
index 0000000..d799671
--- /dev/null
+++ b/src/ui/c-select/c-select.theme.ts
@@ -0,0 +1,60 @@
+import { defineThemes } from '../theme/theme.models';
+import { appThemes } from '../theme/themes';
+
+const sizes = {
+ small: {
+ height: '28px',
+ fontSize: '12px',
+ },
+ medium: {
+ height: '34px',
+ fontSize: '14px',
+ },
+ large: {
+ height: '40px',
+ fontSize: '16px',
+ },
+};
+
+export const { useTheme } = defineThemes({
+ dark: {
+ sizes,
+
+ backgroundColor: '#333333',
+ borderColor: '#333333',
+ dropdownShadow: 'rgba(0, 0, 0, 0.2) 0px 8px 24px',
+
+ option: {
+ hover: {
+ backgroundColor: '#444444',
+ },
+ active: {
+ textColor: appThemes.dark.primary.color,
+ },
+ },
+
+ focus: {
+ backgroundColor: '#1ea54c1a',
+ },
+ },
+ light: {
+ sizes,
+
+ backgroundColor: '#ffffff',
+ borderColor: '#e0e0e69e',
+ dropdownShadow: 'rgba(149, 157, 165, 0.2) 0px 8px 24px',
+
+ option: {
+ hover: {
+ backgroundColor: '#eee',
+ },
+ active: {
+ textColor: appThemes.light.primary.color,
+ },
+ },
+
+ focus: {
+ backgroundColor: '#ffffff',
+ },
+ },
+});
diff --git a/src/ui/c-select/c-select.types.ts b/src/ui/c-select/c-select.types.ts
new file mode 100644
index 0000000..6736b84
--- /dev/null
+++ b/src/ui/c-select/c-select.types.ts
@@ -0,0 +1,4 @@
+export interface CSelectOption<Value = unknown> {
+ label: string
+ value: Value
+}
diff --git a/src/ui/c-select/c-select.vue b/src/ui/c-select/c-select.vue
new file mode 100644
index 0000000..fb34038
--- /dev/null
+++ b/src/ui/c-select/c-select.vue
@@ -0,0 +1,262 @@
+<script setup lang="ts" generic="T extends unknown">
+import { useAppTheme } from '../theme/themes';
+import type { CLabelProps } from '../c-label/c-label.types';
+import type { CSelectOption } from './c-select.types';
+import { useTheme } from './c-select.theme';
+import { clamp } from '@/modules/shared/number.models';
+import { useFuzzySearch } from '@/composable/fuzzySearch';
+
+const props = withDefaults(
+ defineProps<{
+ options?: CSelectOption<T>[] | string[]
+ value?: T
+ placeholder?: string
+ size?: 'small' | 'medium' | 'large'
+ searchable?: boolean
+ } & CLabelProps >(),
+ {
+ options: () => [],
+ value: undefined,
+ placeholder: undefined,
+ size: 'medium',
+ searchable: false,
+ },
+);
+
+const emits = defineEmits(['update:value']);
+
+const { options: rawOptions, placeholder, size: sizeName, searchable } = toRefs(props);
+
+const options = computed(() => {
+ return rawOptions.value.map((option: string | CSelectOption<T>) => {
+ if (typeof option === 'string') {
+ return { label: option, value: option };
+ }
+
+ return option;
+ });
+});
+
+const keys = useMagicKeys();
+const value = useVModel(props, 'value', emits);
+const theme = useTheme();
+const appTheme = useAppTheme();
+
+const isOpen = ref(false);
+const selectedOption = shallowRef<CSelectOption<T> | undefined>(options.value.find((option: CSelectOption<T>) => option.value === value.value));
+const focusIndex = ref(0);
+const elementRef = ref(null);
+
+const size = computed(() => theme.value.sizes[sizeName.value as 'small' | 'medium' | 'large']);
+
+const searchQuery = ref('');
+const searchInputRef = ref();
+
+whenever(() => !isOpen.value, () => {
+ focusIndex.value = 0;
+ searchQuery.value = '';
+});
+
+whenever(() => isOpen.value, () => {
+ nextTick(() => searchInputRef.value?.focus());
+});
+
+onClickOutside(elementRef, close);
+whenever(keys.escape, close);
+
+watch(
+ value,
+ (newValue) => {
+ const option = options.value.find((option: CSelectOption<T>) => option.value === newValue);
+ if (option) {
+ selectedOption.value = option;
+ }
+ },
+);
+
+const { searchResult: filteredOptions } = useFuzzySearch<CSelectOption<T>>({
+ search: searchQuery,
+ data: options.value,
+ options: {
+ keys: ['label'],
+ shouldSort: false,
+ threshold: 0.3,
+ filterEmpty: false,
+ },
+});
+
+function close() {
+ isOpen.value = false;
+}
+
+function toggleOpen() {
+ isOpen.value = !isOpen.value;
+}
+
+function selectOption({ option }: { option: CSelectOption<T> }) {
+ selectedOption.value = option;
+ // @ts-expect-error vue template generic is a bit flacky thanks to withDefaults
+ value.value = option.value;
+ isOpen.value = false;
+}
+
+function handleKeydown(event: KeyboardEvent) {
+ const { key } = event;
+ const isEnter = ['Enter'].includes(key);
+ const isArrowUpOrDown = ['ArrowUp', 'ArrowDown'].includes(key);
+ const isArrowDown = key === 'ArrowDown';
+
+ if (isEnter) {
+ const valueCanBeSelected = isOpen.value && focusIndex.value !== -1;
+
+ if (valueCanBeSelected) {
+ selectOption({ option: filteredOptions.value[focusIndex.value] });
+ }
+ else {
+ toggleOpen();
+ }
+
+ event.preventDefault();
+ return;
+ }
+
+ if (isArrowUpOrDown) {
+ const increment = isArrowDown ? 1 : -1;
+ focusIndex.value = clamp({
+ value: focusIndex.value + increment,
+ min: 0,
+ max: options.value.length - 1,
+ });
+
+ event.preventDefault();
+ }
+}
+
+function onSearchInput() {
+ focusIndex.value = 0;
+}
+</script>
+
+<template>
+ <c-label v-bind="props">
+ <div ref="elementRef" relative class="c-select" w-full>
+ <div
+ flex flex-nowrap cursor-pointer items-center
+ :class="{ 'is-open': isOpen, 'important:border-primary': isOpen }"
+ class="c-select-input"
+ tabindex="0"
+ hover:important:border-primary
+ @click="toggleOpen"
+ @keydown="handleKeydown"
+ >
+ <div flex-1 truncate>
+ <input v-if="searchable && isOpen" ref="searchInputRef" v-model="searchQuery" type="text" placeholder="Search..." class="search-input" w-full lh-normal color-current @input="onSearchInput">
+ <span v-else-if="selectedOption" lh-normal>
+ {{ selectedOption.label }}
+ </span>
+ <span v-else class="placeholder" lh-normal>
+ {{ placeholder ?? 'Select an option' }}
+ </span>
+ </div>
+
+ <icon-mdi-chevron-down class="chevron" />
+ </div>
+
+ <transition name="dropdown">
+ <div v-show="isOpen" class="c-select-dropdown" absolute z-10 mt-1 max-h-312px w-full overflow-y-auto pretty-scrollbar>
+ <template v-if="!filteredOptions.length">
+ <slot name="empty">
+ <div px-4 py-1 opacity-70>
+ No results found
+ </div>
+ </slot>
+ </template>
+ <template v-else>
+ <div
+ v-for="(option, index) in filteredOptions"
+ :key="option.label"
+ cursor-pointer
+ px-4
+ py-1
+ :class="{ active: selectedOption?.label === option.label, hover: focusIndex === index }"
+ class="c-select-dropdown-option"
+ @click="selectOption({ option })"
+ >
+ {{ option.label }}
+ </div>
+ </template>
+ </div>
+ </transition>
+ </div>
+ </c-label>
+</template>
+
+<style lang="less" scoped>
+.c-select {
+ .search-input{
+ all: unset;
+
+ &::placeholder {
+ color: v-bind('appTheme.text.mutedColor');
+ }
+ }
+
+ .c-select-input {
+ background-color: v-bind('theme.backgroundColor');
+ border: 1px solid v-bind('theme.borderColor');
+ border-radius: 4px;
+ padding: 0 12px;
+ font-family: inherit;
+ font-size: v-bind('size.fontSize');
+ height: v-bind('size.height');
+ transition: border-color 0.2s ease-in-out;
+
+ .placeholder, .chevron {
+ color: v-bind('appTheme.text.mutedColor');
+ }
+ }
+
+ .c-select-dropdown {
+ background-color: v-bind('theme.backgroundColor');
+ border-radius: 4px;
+ // box-shadow: rgba(149, 157, 165, 0.2) 0px 8px 24px;
+ box-shadow: v-bind('theme.dropdownShadow');
+ font-family: inherit;
+ font-size: inherit;
+ line-height: 1;
+ padding: 6px;
+
+ .c-select-dropdown-option{
+ border-radius: 4px;
+ padding: 8px 12px;
+ background-color: transparent;
+ transition: background-color 0.2s ease-in-out;
+
+ &.active {
+ color: v-bind('theme.option.active.textColor');
+ }
+
+ &:hover, &.hover {
+ background-color: v-bind('theme.option.hover.backgroundColor');
+ }
+ }
+ }
+}
+
+.dropdown-enter-active,
+.dropdown-leave-active {
+ transition: opacity 0.2s, transform 0.2s;
+}
+
+.dropdown-enter-from,
+.dropdown-leave-to {
+ opacity: 0;
+ transform: translateY(-10px);
+}
+
+.dropdown-enter-to,
+.dropdown-leave-from {
+ opacity: 1;
+ transform: translateY(0);
+}
+</style>
diff --git a/src/ui/demo/demo-home.page.vue b/src/ui/demo/demo-home.page.vue
new file mode 100644
index 0000000..b7c04e9
--- /dev/null
+++ b/src/ui/demo/demo-home.page.vue
@@ -0,0 +1,13 @@
+<script lang="ts" setup>
+import { demoRoutes } from './demo.routes';
+</script>
+
+<template>
+ <div grid grid-cols-5 gap-2>
+ <c-card v-for="{ name } of demoRoutes" :key="name" :title="String(name)">
+ <c-button :to="{ name }">
+ {{ name }}
+ </c-button>
+ </c-card>
+ </div>
+</template>
diff --git a/src/ui/demo/demo.routes.ts b/src/ui/demo/demo.routes.ts
index 9ae1e77..ff514fc 100644
--- a/src/ui/demo/demo.routes.ts
+++ b/src/ui/demo/demo.routes.ts
@@ -1,4 +1,5 @@
import type { RouteRecordRaw } from 'vue-router';
+import DemoHome from './demo-home.page.vue';
const demoPages = import.meta.glob('../*/*.demo.vue');
@@ -17,7 +18,14 @@ export const routes = [
{
path: '/c-lib',
name: 'c-lib',
- children: demoRoutes,
+ children: [
+ {
+ path: '',
+ name: 'c-lib-index',
+ component: DemoHome,
+ },
+ ...demoRoutes,
+ ],
component: () => import('./demo-wrapper.vue'),
},
];
diff --git a/unocss.config.ts b/unocss.config.ts
index 06a9215..0ce8f70 100644
--- a/unocss.config.ts
+++ b/unocss.config.ts
@@ -7,12 +7,17 @@ import {
transformerVariantGroup,
} from 'unocss';
+import { presetScrollbar } from 'unocss-preset-scrollbar';
+
export default defineConfig({
- presets: [presetUno(), presetAttributify(), presetTypography()],
+ presets: [presetUno(), presetAttributify(), presetTypography(), presetScrollbar()],
transformers: [transformerDirectives(), transformerVariantGroup()],
theme: {
colors: {
primary: '#1ea54c',
},
},
+ shortcuts: {
+ 'pretty-scrollbar': 'scrollbar scrollbar-rounded scrollbar-thumb-color-gray-300 scrollbar-track-color-gray-100 dark:scrollbar-thumb-color-#424242 dark:scrollbar-track-color-#686868',
+ },
});