diff options
author | 2023-08-07 17:30:00 +0200 | |
---|---|---|
committer | 2023-08-07 15:30:00 +0000 | |
commit | dfa1ba85548508e680f68200ea521be95c3eafe0 (patch) | |
tree | c166b635e5eb006806bd40a88252d90735be9ca4 | |
parent | 6498c9b0fa0427d567506dbd4a6e87d227b138d4 (diff) | |
download | it-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
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', + }, }); |