├── src ├── assets │ ├── scss │ │ ├── objects │ │ │ ├── _index.scss │ │ │ └── _transition.scss │ │ ├── utilities │ │ │ ├── _index.scss │ │ │ └── _hide-scrollbar.scss │ │ ├── style.scss │ │ ├── abstracts │ │ │ ├── _z-index.scss │ │ │ ├── _functions.scss │ │ │ ├── _breakpoints.scss │ │ │ ├── _index.scss │ │ │ ├── _shape.scss │ │ │ ├── _elevation.scss │ │ │ ├── _colors.scss │ │ │ ├── _typography.scss │ │ │ ├── _variables.scss │ │ │ └── _mixin.scss │ │ ├── index.scss │ │ ├── base │ │ │ ├── _index.scss │ │ │ ├── _base.scss │ │ │ ├── _components.scss │ │ │ ├── _theme.scss │ │ │ ├── _fonts.scss │ │ │ └── _reset.scss │ │ ├── themes │ │ │ ├── _index.scss │ │ │ ├── _dark-theme.scss │ │ │ └── _light-theme.scss │ │ └── components │ │ │ ├── _skeleton.scss │ │ │ ├── _toast-manager.scss │ │ │ ├── _index.scss │ │ │ ├── _dialog.scss │ │ │ ├── _side-sheet.scss │ │ │ └── _date-picker.scss │ ├── fonts │ │ └── roboto │ │ │ ├── roboto-bold.ttf │ │ │ ├── roboto-medium.ttf │ │ │ └── roboto-regular.ttf │ └── icons │ │ ├── filled │ │ ├── remove.icon.vue │ │ ├── arrow-drop-up.icon.vue │ │ ├── arrow-drop-down.icon.vue │ │ ├── check.icon.vue │ │ ├── chevron-left.icon.vue │ │ ├── chevron-right.icon.vue │ │ ├── favorite.icon.vue │ │ ├── close.icon.vue │ │ ├── add.icon.vue │ │ ├── arrow-back.icon.vue │ │ ├── arrow-forward.icon.vue │ │ └── add-circle.icon.vue │ │ └── outlined │ │ └── favorite.icon.vue ├── plug-in │ ├── router │ │ └── install.js │ ├── vee-validate │ │ ├── index.js │ │ ├── rules.js │ │ ├── install.js │ │ └── composable │ │ │ └── use-input.composable.js │ ├── i18n │ │ ├── index.js │ │ ├── dictionary │ │ │ ├── fa.json │ │ │ ├── en.json │ │ │ └── index.js │ │ ├── utils │ │ │ └── double-bracket-replace.util.js │ │ ├── install.js │ │ ├── composable │ │ │ ├── use-locale.js │ │ │ └── use-translate.js │ │ └── translate.js │ ├── toast │ │ ├── index.js │ │ ├── composable │ │ │ └── use-toast.composable.js │ │ ├── install.js │ │ ├── constants │ │ │ └── toast.constant.js │ │ ├── components │ │ │ ├── toast-notification.component.vue │ │ │ └── toast-manager.component.vue │ │ └── toast-manager.js │ ├── axios │ │ ├── middlewares │ │ │ ├── index.js │ │ │ ├── on-response │ │ │ │ ├── responsibilities.js │ │ │ │ └── index.js │ │ │ └── on-request │ │ │ │ ├── responsibilities.js │ │ │ │ └── index.js │ │ ├── index.js │ │ ├── install.js │ │ └── default-config-builder │ │ │ └── index.js │ └── index.js ├── utils │ ├── index.js │ ├── math.util.js │ ├── uuid.util.js │ ├── object.util.js │ ├── dom.util.js │ ├── deep-clone.util.js │ └── timeout.util.js ├── services │ ├── event-bus │ │ ├── index.js │ │ └── event-bus.service.js │ ├── calendar │ │ └── gregorian │ │ │ ├── index.js │ │ │ └── gregorian-calendar.service.js │ └── mask │ │ └── index.js ├── layouts │ └── default.layout.vue ├── constants │ ├── mask │ │ └── mask.constant.js │ ├── calendar │ │ └── gregorian.constant.js │ └── router │ │ └── routes.constant.js ├── router │ ├── middlewares │ │ ├── index.js │ │ ├── before-each.js │ │ └── after-each.js │ └── index.js ├── App.vue ├── components │ ├── common │ │ ├── router │ │ │ ├── transition.component.vue │ │ │ └── indicator.component.vue │ │ ├── layout-view │ │ │ └── layout-view.component.vue │ │ ├── picker │ │ │ └── date │ │ │ │ ├── header.vue │ │ │ │ ├── control-menu.vue │ │ │ │ ├── year-picker.vue │ │ │ │ └── base-calendar.vue │ │ ├── scrim │ │ │ ├── container.component.vue │ │ │ └── item.component.vue │ │ ├── sheet │ │ │ └── side │ │ │ │ ├── modal.component.vue │ │ │ │ └── standard.component.vue │ │ ├── skeleton │ │ │ └── base.component.vue │ │ ├── snack-bar │ │ │ └── snack-bar.component.vue │ │ ├── icon │ │ │ └── base.component.vue │ │ ├── input │ │ │ ├── radio.component.vue │ │ │ ├── stepper.component.vue │ │ │ └── switch.component.vue │ │ ├── progress-indicator │ │ │ ├── linear.component.vue │ │ │ └── circular.component.vue │ │ ├── dialog │ │ │ └── basic.component.vue │ │ ├── button │ │ │ ├── icon.component.vue │ │ │ ├── segmented.component.vue │ │ │ └── base.component.vue │ │ ├── chip │ │ │ └── filter.component.vue │ │ └── field │ │ │ └── filled-text.component.vue │ ├── view │ │ └── presentation │ │ │ ├── date-picker.component.vue │ │ │ ├── snackbar.component.vue │ │ │ ├── switch.component.vue │ │ │ ├── radio-input.component.vue │ │ │ ├── progress-bar.component.vue │ │ │ ├── segmented-button.component.vue │ │ │ ├── skeleton.component.vue │ │ │ ├── stepper-input.component.vue │ │ │ ├── icon-button.component.vue │ │ │ ├── filter-chip.component.vue │ │ │ └── button.component.vue │ └── index.js ├── main.js ├── composable │ ├── use-event-bus.composable.js │ ├── use-promise.composable.js │ └── use-draggable.composable.js ├── views │ ├── playground │ │ └── playground-view.vue │ └── presentation │ │ └── presentation-view.vue └── interfaces │ └── calendar │ └── calendar.interface.js ├── public └── favicon.ico ├── .vscode └── extensions.json ├── .prettierrc.json ├── .eslintrc.cjs ├── index.html ├── .gitignore ├── vite.config.js ├── package.json └── README.md /src/assets/scss/objects/_index.scss: -------------------------------------------------------------------------------- 1 | @forward './transition'; 2 | -------------------------------------------------------------------------------- /src/assets/scss/utilities/_index.scss: -------------------------------------------------------------------------------- 1 | @forward './hide-scrollbar'; 2 | -------------------------------------------------------------------------------- /src/plug-in/router/install.js: -------------------------------------------------------------------------------- 1 | export { default } from '@/router'; 2 | -------------------------------------------------------------------------------- /src/assets/scss/style.scss: -------------------------------------------------------------------------------- 1 | @use './base' as *; 2 | @use './objects' as *; 3 | -------------------------------------------------------------------------------- /src/assets/scss/abstracts/_z-index.scss: -------------------------------------------------------------------------------- 1 | $route-indicator: 99; 2 | $toast: 98; 3 | $scrim: 97; 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0Amirreza0/vue3-boilerplate/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"] 3 | } 4 | -------------------------------------------------------------------------------- /src/assets/scss/index.scss: -------------------------------------------------------------------------------- 1 | @forward './abstracts'; 2 | @forward './components'; 3 | @forward './themes'; 4 | @forward './utilities'; 5 | -------------------------------------------------------------------------------- /src/plug-in/vee-validate/index.js: -------------------------------------------------------------------------------- 1 | export { default as useInput } from '@/plug-in/vee-validate/composable/use-input.composable.js'; 2 | -------------------------------------------------------------------------------- /src/assets/fonts/roboto/roboto-bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0Amirreza0/vue3-boilerplate/HEAD/src/assets/fonts/roboto/roboto-bold.ttf -------------------------------------------------------------------------------- /src/assets/fonts/roboto/roboto-medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0Amirreza0/vue3-boilerplate/HEAD/src/assets/fonts/roboto/roboto-medium.ttf -------------------------------------------------------------------------------- /src/assets/fonts/roboto/roboto-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0Amirreza0/vue3-boilerplate/HEAD/src/assets/fonts/roboto/roboto-regular.ttf -------------------------------------------------------------------------------- /src/assets/scss/abstracts/_functions.scss: -------------------------------------------------------------------------------- 1 | @use './variables' as *; 2 | 3 | @function space($count) { 4 | @return $base-space * $count; 5 | } 6 | -------------------------------------------------------------------------------- /src/assets/scss/abstracts/_breakpoints.scss: -------------------------------------------------------------------------------- 1 | $breakpoints: ( 2 | large: 1599px, 3 | expanded: 1199px, 4 | medium: 839px, 5 | compact: 599px, 6 | ); 7 | -------------------------------------------------------------------------------- /src/assets/scss/base/_index.scss: -------------------------------------------------------------------------------- 1 | @forward './reset'; 2 | @forward './fonts'; 3 | @forward './theme'; 4 | @forward './base'; 5 | @forward './components'; 6 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | export { default as deepClone } from '@/utils/deep-clone.util.js'; 2 | export { default as Timeout } from '@/utils/timeout.util.js'; 3 | -------------------------------------------------------------------------------- /src/plug-in/i18n/index.js: -------------------------------------------------------------------------------- 1 | export { default as useLocale } from './composable/use-locale'; 2 | export { default as useTranslate } from './composable/use-translate'; 3 | -------------------------------------------------------------------------------- /src/assets/scss/themes/_index.scss: -------------------------------------------------------------------------------- 1 | @use './light-theme' as *; 2 | @use './dark-theme' as *; 3 | 4 | $themes: ( 5 | light: $light-theme, 6 | dark: $dark-theme, 7 | ); 8 | -------------------------------------------------------------------------------- /src/services/event-bus/index.js: -------------------------------------------------------------------------------- 1 | import EventBus from '@/services/event-bus/event-bus.service'; 2 | 3 | const eventBus = new EventBus(); 4 | 5 | export default eventBus; 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "printWidth": 80, 6 | "trailingComma": "es5", 7 | "vueIndentScriptAndStyle": true 8 | } 9 | -------------------------------------------------------------------------------- /src/assets/scss/base/_base.scss: -------------------------------------------------------------------------------- 1 | body { 2 | min-height: 100vh; 3 | overflow: hidden auto; 4 | font-family: 'Roboto'; 5 | background-color: var(--palette-surface-container-lowest); 6 | } 7 | -------------------------------------------------------------------------------- /src/assets/scss/components/_skeleton.scss: -------------------------------------------------------------------------------- 1 | $skeleton: ( 2 | skeleton-from-color: var(--palette-surface-container-high), 3 | skeleton-to-color: var(--palette-surface-container-low), 4 | ); 5 | -------------------------------------------------------------------------------- /src/assets/scss/utilities/_hide-scrollbar.scss: -------------------------------------------------------------------------------- 1 | %hide-scrollbar { 2 | -ms-overflow-style: none; 3 | scrollbar-width: none; 4 | &::-webkit-scrollbar { 5 | display: none; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/plug-in/toast/index.js: -------------------------------------------------------------------------------- 1 | export { default as useToast } from './composable/use-toast.composable.js'; 2 | export { default as ToastManager } from './components/toast-manager.component.vue'; 3 | -------------------------------------------------------------------------------- /src/assets/scss/components/_toast-manager.scss: -------------------------------------------------------------------------------- 1 | @use '../abstracts/functions' as *; 2 | 3 | $toast-manager: ( 4 | toast-stack-margin: space(6), 5 | toast-stack-item-margin: space(2) 0 0, 6 | ); 7 | -------------------------------------------------------------------------------- /src/layouts/default.layout.vue: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /src/plug-in/toast/composable/use-toast.composable.js: -------------------------------------------------------------------------------- 1 | import { inject } from 'vue'; 2 | 3 | const useToast = () => { 4 | const toast = inject('toast'); 5 | return toast; 6 | }; 7 | 8 | export default useToast; 9 | -------------------------------------------------------------------------------- /src/plug-in/axios/middlewares/index.js: -------------------------------------------------------------------------------- 1 | export { default as requestMiddlewares } from '@/plug-in/axios/middlewares/on-request'; 2 | export { default as responseMiddlewares } from '@/plug-in/axios/middlewares/on-response'; 3 | -------------------------------------------------------------------------------- /src/plug-in/i18n/dictionary/fa.json: -------------------------------------------------------------------------------- 1 | { 2 | "validationErrors": { 3 | "required": "این فیلد اجباریست", 4 | "betweenLength": "مقدار باید بین {{0}} و {{1}} باشد" 5 | }, 6 | "playground": { 7 | "title": "زمین بازی" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/plug-in/i18n/dictionary/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "validationErrors": { 3 | "required": "This Field is required", 4 | "betweenLength": "Must be between {{0}} and {{1}}" 5 | }, 6 | "playground": { 7 | "title": "Playground" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/assets/scss/base/_components.scss: -------------------------------------------------------------------------------- 1 | @use '@/assets/scss/components' as *; 2 | 3 | :root { 4 | @each $component, $variables in $components { 5 | @each $variable, $value in $variables { 6 | --#{$variable}: #{$value}; 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/assets/scss/abstracts/_index.scss: -------------------------------------------------------------------------------- 1 | @forward './colors'; 2 | @forward './variables'; 3 | @forward './mixin'; 4 | @forward './typography'; 5 | @forward './breakpoints'; 6 | @forward './shape'; 7 | @forward './functions'; 8 | @forward './elevation'; 9 | @forward './z-index'; 10 | -------------------------------------------------------------------------------- /src/assets/scss/base/_theme.scss: -------------------------------------------------------------------------------- 1 | @use '../themes' as *; 2 | 3 | :root { 4 | @each $theme-name, $theme in $themes { 5 | &[data-theme='#{$theme-name}'] { 6 | @each $name, $value in $theme { 7 | --palette-#{$name}: #{$value}; 8 | } 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/constants/mask/mask.constant.js: -------------------------------------------------------------------------------- 1 | export const REGEX_STARTING_CHAR = '^'; 2 | export const REGEX_ENDING_CHAR = '$'; 3 | export const REGEX_TOKEN = '[^.]'; 4 | export const DEFAULT_TOKEN = '#'; 5 | 6 | export default { 7 | REGEX_STARTING_CHAR, 8 | REGEX_ENDING_CHAR, 9 | REGEX_TOKEN, 10 | DEFAULT_TOKEN, 11 | }; 12 | -------------------------------------------------------------------------------- /src/assets/scss/components/_index.scss: -------------------------------------------------------------------------------- 1 | @use './toast-manager.scss' as *; 2 | @use './date-picker.scss' as *; 3 | @use './side-sheet.scss' as *; 4 | @use './dialog.scss' as *; 5 | @use './skeleton.scss' as *; 6 | 7 | $components: ( 8 | toast-manager: $toast-manager, 9 | side-sheet: $side-sheet, 10 | skeleton: $skeleton, 11 | ); 12 | -------------------------------------------------------------------------------- /src/assets/icons/filled/remove.icon.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /src/plug-in/i18n/utils/double-bracket-replace.util.js: -------------------------------------------------------------------------------- 1 | export const doubleBracketReplace = (value, replacementMap = {}) => { 2 | const doubleBracketRegex = /{{(.*?)}}/g; 3 | 4 | return value.replace(doubleBracketRegex, (match, captured) => { 5 | const sanitizedCaptured = captured.trim(); 6 | return replacementMap[sanitizedCaptured] || match; 7 | }); 8 | }; 9 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | require('@rushstack/eslint-patch/modern-module-resolution') 3 | 4 | module.exports = { 5 | root: true, 6 | 'extends': [ 7 | 'plugin:vue/vue3-essential', 8 | 'eslint:recommended', 9 | '@vue/eslint-config-prettier/skip-formatting' 10 | ], 11 | parserOptions: { 12 | ecmaVersion: 'latest' 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/assets/icons/filled/arrow-drop-up.icon.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /src/assets/icons/filled/arrow-drop-down.icon.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /src/services/calendar/gregorian/index.js: -------------------------------------------------------------------------------- 1 | import GregorianCalendar from './gregorian-calendar.service'; 2 | import { 3 | WEEK_DAYS_LIST, 4 | MONTH_LIST, 5 | } from '@/constants/calendar/gregorian.constant.js'; 6 | 7 | const gregorianCalendar = new GregorianCalendar({ 8 | monthList: MONTH_LIST, 9 | weekDayList: WEEK_DAYS_LIST, 10 | }); 11 | 12 | export default gregorianCalendar; 13 | -------------------------------------------------------------------------------- /src/assets/icons/filled/check.icon.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /src/plug-in/toast/install.js: -------------------------------------------------------------------------------- 1 | import ToastManager from './toast-manager.js'; 2 | import ToastManagerComponent from './components/toast-manager.component.vue'; 3 | 4 | export default (vueInstance, config) => { 5 | const toastManager = new ToastManager(config); 6 | 7 | vueInstance.provide('toast', toastManager); 8 | vueInstance.component('toast-manager', ToastManagerComponent); 9 | }; 10 | -------------------------------------------------------------------------------- /src/assets/icons/filled/chevron-left.icon.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /src/assets/icons/filled/chevron-right.icon.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /src/utils/math.util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Clamp value between min and max values 3 | * @param {number} value 4 | * @param {Object} options 5 | * @param {number} [options.min=0] 6 | * @param {number} options.max 7 | * @returns {number} - clamped value 8 | */ 9 | export const clamp = (value, { max, min = 0 }) => { 10 | return Math.min(Math.max(min, value), max); 11 | }; 12 | 13 | export default { clamp }; 14 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite App 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/constants/calendar/gregorian.constant.js: -------------------------------------------------------------------------------- 1 | export const MONTH_LIST = [ 2 | 'January', 3 | 'February', 4 | 'March', 5 | 'April', 6 | 'May', 7 | 'June', 8 | 'July', 9 | 'August', 10 | 'September', 11 | 'October', 12 | 'November', 13 | 'December', 14 | ]; 15 | 16 | export const WEEK_DAYS_LIST = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; 17 | 18 | export default { WEEK_DAYS_LIST, MONTH_LIST }; 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist 13 | dist-ssr 14 | coverage 15 | *.local 16 | 17 | /cypress/videos/ 18 | /cypress/screenshots/ 19 | 20 | # Editor directories and files 21 | .vscode/* 22 | !.vscode/extensions.json 23 | .idea 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | -------------------------------------------------------------------------------- /src/assets/scss/abstracts/_shape.scss: -------------------------------------------------------------------------------- 1 | $border-radius-base: 4px; 2 | 3 | $radius-1x: $border-radius-base; 4 | $radius-2x: $border-radius-base * 2; 5 | $radius-3x: $border-radius-base * 3; 6 | $radius-4x: $border-radius-base * 4; 7 | $radius-5x: $border-radius-base * 5; 8 | $radius-6x: $border-radius-base * 6; 9 | $radius-7x: $border-radius-base * 7; 10 | $radius-8x: $border-radius-base * 8; 11 | 12 | $pill: 9999px; 13 | $circle: 50%; 14 | -------------------------------------------------------------------------------- /src/assets/icons/filled/favorite.icon.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /src/assets/scss/base/_fonts.scss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: Roboto; 3 | font-weight: 400; 4 | src: url('@/assets/fonts/roboto/roboto-regular.ttf'); 5 | } 6 | 7 | @font-face { 8 | font-family: Roboto; 9 | font-weight: 500; 10 | src: url('@/assets/fonts/roboto/roboto-medium.ttf'); 11 | } 12 | 13 | @font-face { 14 | font-family: Roboto; 15 | font-weight: 700; 16 | src: url('@/assets/fonts/roboto/roboto-bold.ttf'); 17 | } 18 | -------------------------------------------------------------------------------- /src/assets/scss/base/_reset.scss: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | font-family: inherit; 4 | } 5 | 6 | body, 7 | p, 8 | h1, 9 | h2, 10 | h3, 11 | h4, 12 | h5, 13 | h6 { 14 | margin: 0; 15 | } 16 | 17 | ol, 18 | ul, 19 | menu { 20 | list-style: none; 21 | margin: 0; 22 | padding: 0; 23 | } 24 | 25 | input, 26 | button { 27 | background-color: transparent; 28 | outline: none; 29 | border: none; 30 | padding: 0; 31 | } 32 | -------------------------------------------------------------------------------- /src/utils/uuid.util.js: -------------------------------------------------------------------------------- 1 | export const generateUUID = () => { 2 | const pattern = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'; 3 | 4 | return pattern.replace(/[xy]/g, (placeholder) => { 5 | const randomValue = (Math.random() * 16) | 0; 6 | const value = placeholder === 'x' ? randomValue : (randomValue & 0x3) | 0x8; 7 | 8 | return value.toString(16); 9 | }); 10 | }; 11 | 12 | export default { 13 | generate: generateUUID, 14 | }; 15 | -------------------------------------------------------------------------------- /src/plug-in/axios/index.js: -------------------------------------------------------------------------------- 1 | import Axios from 'axios'; 2 | 3 | class AxiosSingleton { 4 | static #instance; 5 | 6 | constructor(options) { 7 | return Axios.create(options); 8 | } 9 | 10 | static getInstance(options) { 11 | if (!AxiosSingleton.#instance) { 12 | AxiosSingleton.#instance = new AxiosSingleton(options); 13 | } 14 | 15 | return AxiosSingleton.#instance; 16 | } 17 | } 18 | 19 | export default AxiosSingleton; 20 | -------------------------------------------------------------------------------- /src/plug-in/i18n/install.js: -------------------------------------------------------------------------------- 1 | import { translate } from './translate.js'; 2 | import { useLocale } from './index.js'; 3 | 4 | export default (vueInstance, config) => { 5 | const { setLocale, locale: defaultLocale } = useLocale(); 6 | setLocale(config?.defaultLocale ?? 'en'); 7 | 8 | vueInstance.config.globalProperties.$t = ( 9 | query, 10 | locale = defaultLocale.value 11 | ) => { 12 | return translate(query, locale); 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /src/router/middlewares/index.js: -------------------------------------------------------------------------------- 1 | import afterEach from '@/router/middlewares/after-each.js'; 2 | import beforeEach from '@/router/middlewares/before-each.js'; 3 | 4 | /** 5 | * Apply beforeEach and afterEach middlewares 6 | * @param {Object} router - router instance 7 | */ 8 | const applyMiddlewares = (router) => { 9 | router.afterEach(afterEach); 10 | router.beforeEach(beforeEach); 11 | }; 12 | 13 | export default { apply: applyMiddlewares }; 14 | -------------------------------------------------------------------------------- /src/assets/icons/filled/close.icon.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /src/plug-in/vee-validate/rules.js: -------------------------------------------------------------------------------- 1 | const required = (value) => { 2 | if (!value || !value.length) { 3 | return false; 4 | } 5 | 6 | return true; 7 | }; 8 | 9 | const betweenLength = (value, [min, max]) => { 10 | if (!value || !value.length) { 11 | return true; 12 | } 13 | 14 | if (value.length < min || value.length > max) { 15 | return false; 16 | } 17 | 18 | return true; 19 | }; 20 | 21 | export default { required, betweenLength }; 22 | -------------------------------------------------------------------------------- /src/assets/icons/filled/add.icon.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /src/assets/icons/filled/arrow-back.icon.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /src/assets/icons/filled/arrow-forward.icon.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHashHistory } from 'vue-router'; 2 | import ROUTES_MAP from '@/constants/router/routes.constant.js'; 3 | import Middlewares from '@/router/middlewares'; 4 | 5 | const routes = Object.values(ROUTES_MAP).map((routeConfig) => routeConfig); 6 | 7 | const router = createRouter({ 8 | history: createWebHashHistory(import.meta.env.BASE_URL), 9 | routes, 10 | }); 11 | 12 | Middlewares.apply(router); 13 | 14 | export default router; 15 | -------------------------------------------------------------------------------- /src/utils/object.util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Check if the given entry is object and it's empty 3 | * @param {Object} entry 4 | * @returns {boolean} - entry emptiness 5 | */ 6 | export const isObjectEmpty = (entry) => { 7 | if (!entry) return false; 8 | 9 | const isObject = entry?.constructor === Object; 10 | if (!isObject) return false; 11 | 12 | const isEmpty = Object.keys(entry).length === 0; 13 | return isEmpty; 14 | }; 15 | 16 | export default { 17 | isEmpty: isObjectEmpty, 18 | }; 19 | -------------------------------------------------------------------------------- /src/router/middlewares/before-each.js: -------------------------------------------------------------------------------- 1 | import eventBus from '@/services/event-bus'; 2 | 3 | /** 4 | * Publish beforeEach happened event 5 | */ 6 | const publishEvent = () => { 7 | eventBus.publish('router:beforeEach'); 8 | }; 9 | 10 | /** 11 | * BeforeEach middlewares call 12 | * @param {Object} to - VueRouter `to` route object 13 | * @param {Object} from - VueRouter `from` route object 14 | */ 15 | const beforeEach = (to, from) => { 16 | publishEvent(to, from); 17 | }; 18 | 19 | export default beforeEach; 20 | -------------------------------------------------------------------------------- /src/utils/dom.util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Move cursor to the end if focused 3 | * @param {HTMLElement} inputElement 4 | */ 5 | export const moveCursorToEnd = (inputElement) => { 6 | const selection = window.getSelection(); 7 | 8 | if (selection.rangeCount > 0) { 9 | selection.removeAllRanges(); 10 | } 11 | 12 | const range = document.createRange(); 13 | 14 | range.selectNodeContents(inputElement); 15 | range.collapse(false); 16 | 17 | selection.addRange(range); 18 | }; 19 | 20 | export default { moveCursorToEnd }; 21 | -------------------------------------------------------------------------------- /src/assets/scss/components/_dialog.scss: -------------------------------------------------------------------------------- 1 | @use '../abstracts/mixin' as *; 2 | 3 | .dialog-enter-active { 4 | @include transition(emphasized-decelerate) { 5 | transform-origin: top; 6 | transition-property: transform, opacity; 7 | } 8 | } 9 | 10 | .dialog-leave-active { 11 | @include transition(emphasized-accelerate) { 12 | transform-origin: top; 13 | transition-property: transform, opacity; 14 | } 15 | } 16 | 17 | .dialog-leave-to, 18 | .dialog-enter-from { 19 | transform: scaleY(0); 20 | opacity: 0; 21 | } 22 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url'; 2 | 3 | import { defineConfig } from 'vite'; 4 | import vue from '@vitejs/plugin-vue'; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [vue()], 9 | resolve: { 10 | alias: { 11 | '@': fileURLToPath(new URL('./src', import.meta.url)), 12 | }, 13 | }, 14 | css: { 15 | preprocessorOptions: { 16 | scss: { 17 | additionalData: `@use "@/assets/scss/index.scss" as *;`, 18 | }, 19 | }, 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /src/plug-in/index.js: -------------------------------------------------------------------------------- 1 | const pluginSourceMap = import.meta.globEager(['@/plug-in/*/install.js'], { 2 | import: 'default', 3 | }); 4 | 5 | const pluginSourceList = Object.keys(pluginSourceMap); 6 | 7 | export const registerPlugins = (vueInstance, pluginConfigMap = {}) => { 8 | pluginSourceList.forEach((source) => { 9 | const plugin = pluginSourceMap[source]; 10 | 11 | const [pluginName] = source.split('/').slice(-2); 12 | const config = pluginConfigMap[pluginName]; 13 | 14 | vueInstance.use(plugin, config); 15 | }); 16 | }; 17 | -------------------------------------------------------------------------------- /src/utils/deep-clone.util.js: -------------------------------------------------------------------------------- 1 | export const deepClone = (itemToClone) => { 2 | if (!itemToClone) return itemToClone; 3 | 4 | const isArray = Array.isArray(itemToClone); 5 | if (isArray) return itemToClone.map((item) => deepClone(item)); 6 | 7 | const isObject = typeof itemToClone === 'object'; 8 | 9 | if (!isObject) return itemToClone; 10 | 11 | const objectClone = {}; 12 | 13 | Object.keys(itemToClone).forEach((key) => { 14 | const item = itemToClone[key]; 15 | objectClone[key] = deepClone(item); 16 | }); 17 | 18 | return objectClone; 19 | }; 20 | 21 | export default deepClone; 22 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 26 | -------------------------------------------------------------------------------- /src/components/common/router/transition.component.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 25 | -------------------------------------------------------------------------------- /src/plug-in/vee-validate/install.js: -------------------------------------------------------------------------------- 1 | import { configure, defineRule } from 'vee-validate'; 2 | import allRules from '@/plug-in/vee-validate/rules.js'; 3 | 4 | const messageGenerator = ({ rule }) => { 5 | return rule; 6 | }; 7 | 8 | const setupRules = (rules) => { 9 | Object.entries(rules).forEach(([ruleName, validator]) => { 10 | defineRule(ruleName, validator); 11 | }); 12 | }; 13 | 14 | const DEFAULT_CONFIG = Object.freeze({ 15 | generateMessage: messageGenerator, 16 | }); 17 | 18 | export default (_, userConfig) => { 19 | setupRules(allRules); 20 | 21 | const config = { ...DEFAULT_CONFIG, ...userConfig }; 22 | configure(config); 23 | }; 24 | -------------------------------------------------------------------------------- /src/assets/icons/outlined/favorite.icon.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp as createVueApp } from 'vue'; 2 | 3 | import App from '@/App.vue'; 4 | import { registerPlugins } from '@/plug-in'; 5 | import { registerCommonComponents } from '@/components'; 6 | 7 | import '@/assets/scss/style.scss'; 8 | 9 | //TODO: move to constants or configs 10 | const pluginConfigs = { 11 | i18n: { 12 | defaultLocale: 'en', 13 | }, 14 | }; 15 | 16 | const createApplication = () => { 17 | const vueAppInstance = createVueApp(App); 18 | 19 | registerPlugins(vueAppInstance, pluginConfigs); 20 | registerCommonComponents(vueAppInstance, ['scrim']); 21 | 22 | vueAppInstance.mount('#app'); 23 | }; 24 | 25 | createApplication(); 26 | -------------------------------------------------------------------------------- /src/plug-in/toast/constants/toast.constant.js: -------------------------------------------------------------------------------- 1 | export const DEFAULT_CONFIG = Object.freeze({ 2 | duration: 3000, 3 | position: 'bottom', 4 | type: 'info', 5 | autoDismiss: true, 6 | stackMaxToast: 3, 7 | }); 8 | 9 | export const TOAST_POSITIONS = [ 10 | 'top', 11 | 'bottom', 12 | 'top-center', 13 | 'top-left', 14 | 'top-right', 15 | 'bottom-center', 16 | 'bottom-right', 17 | 'bottom-left', 18 | ]; 19 | 20 | export const STACK_MAP = { 21 | top: [], 22 | bottom: [], 23 | 'top-center': [], 24 | 'top-left': [], 25 | 'top-right': [], 26 | 'bottom-center': [], 27 | 'bottom-right': [], 28 | 'bottom-left': [], 29 | }; 30 | 31 | export default { POSITIONS: TOAST_POSITIONS, DEFAULT_CONFIG, STACK_MAP }; 32 | -------------------------------------------------------------------------------- /src/plug-in/axios/middlewares/on-response/responsibilities.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Build response payload 3 | * @param {Object} response - AxiosResponse object 4 | * @param {Object} context 5 | * @param {Object} context.vueInstance - Current vue instance 6 | * @returns {any} - Modified response 7 | */ 8 | const getResponsePayload = (response) => response?.data; 9 | 10 | /** 11 | * Build error payload 12 | * @param {Object} error - AxiosError object 13 | * @param {Object} context 14 | * @param {Object} context.vueInstance - Current vue instance 15 | * @returns {any} - Modified error 16 | */ 17 | const getErrorPayload = (error) => Promise.reject(error.response); 18 | 19 | export default { 20 | getErrorPayload, 21 | getResponsePayload, 22 | }; 23 | -------------------------------------------------------------------------------- /src/plug-in/axios/middlewares/on-request/responsibilities.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Build request payload 3 | * @param {Object} request - AxiosRequest object 4 | * @param {Object} context 5 | * @param {Object} context.vueInstance - Current vue instance 6 | * @returns {any} - Modified response 7 | */ 8 | const getRequestPayload = (request) => { 9 | return request; 10 | }; 11 | 12 | /** 13 | * Build error payload 14 | * @param {Object} error - AxiosError object 15 | * @param {Object} context 16 | * @param {Object} context.vueInstance - Current vue instance 17 | * @returns {any} - Modified error 18 | */ 19 | const getErrorPayload = (error) => { 20 | return Promise.reject(error); 21 | }; 22 | 23 | export default { 24 | getRequestPayload, 25 | getErrorPayload, 26 | }; 27 | -------------------------------------------------------------------------------- /src/constants/router/routes.constant.js: -------------------------------------------------------------------------------- 1 | export const PRESENTATION_ROUTE = Object.freeze({ 2 | name: 'presentation', 3 | path: '/presentation', 4 | component: () => import('@/views/presentation/presentation-view.vue'), 5 | meta: { 6 | title: 'Presentation | Boilerplate', 7 | }, 8 | }); 9 | 10 | export const PLAYGROUND_ROUTE = Object.freeze({ 11 | name: 'playground', 12 | path: '/playground', 13 | component: () => import('@/views/playground/playground-view.vue'), 14 | meta: { 15 | title: 'Playground | Boilerplate', 16 | }, 17 | }); 18 | 19 | export const ROOT_ROUTE = Object.freeze({ 20 | name: 'root', 21 | path: '/', 22 | redirect: { name: 'presentation' }, 23 | }); 24 | 25 | export default { PRESENTATION_ROUTE, PLAYGROUND_ROUTE, ROOT_ROUTE }; 26 | -------------------------------------------------------------------------------- /src/components/view/presentation/date-picker.component.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 14 | 15 | 29 | -------------------------------------------------------------------------------- /src/assets/icons/filled/add-circle.icon.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /src/assets/scss/objects/_transition.scss: -------------------------------------------------------------------------------- 1 | @use '../abstracts/_mixin' as *; 2 | 3 | .scale-fade-enter-active { 4 | @include transition(standard-decelerate) { 5 | transition-property: opacity, transform; 6 | } 7 | } 8 | 9 | .scale-fade-leave-active { 10 | @include transition(standard-accelerate) { 11 | transition-property: opacity, transform; 12 | } 13 | } 14 | 15 | .scale-fade-enter-from, 16 | .scale-fade-leave-to { 17 | opacity: 0; 18 | transform: scale(0.9); 19 | } 20 | 21 | .fade-enter-active { 22 | @include transition(standard-decelerate) { 23 | transition-property: opacity; 24 | } 25 | } 26 | 27 | .fade-leave-active { 28 | @include transition(standard-accelerate) { 29 | transition-property: opacity; 30 | } 31 | } 32 | 33 | .fade-leave-to, 34 | .fade-enter-from { 35 | opacity: 0; 36 | } 37 | -------------------------------------------------------------------------------- /src/plug-in/i18n/dictionary/index.js: -------------------------------------------------------------------------------- 1 | const dictionaryMap = import.meta.globEager( 2 | ['@/plug-in/i18n/dictionary/*.json'], 3 | { import: 'default' } 4 | ); 5 | 6 | export const supportedLocaleList = []; 7 | export const rtlLocaleList = ['fa']; 8 | 9 | const dictionaryPathList = Object.keys(dictionaryMap); 10 | export const dictionaries = dictionaryPathList.reduce( 11 | (dictionaries, dictionaryPath) => { 12 | const dictionary = dictionaryMap[dictionaryPath]; 13 | 14 | const [dictionaryFileName] = dictionaryPath.split('/').slice(-1); 15 | const [dictionaryName] = dictionaryFileName.split('.json'); 16 | 17 | supportedLocaleList.push(dictionaryName); 18 | 19 | return { ...dictionaries, [dictionaryName]: dictionary }; 20 | }, 21 | {} 22 | ); 23 | 24 | export default { dictionaries, supportedLocaleList }; 25 | -------------------------------------------------------------------------------- /src/router/middlewares/after-each.js: -------------------------------------------------------------------------------- 1 | import eventBus from '@/services/event-bus'; 2 | 3 | /** 4 | * Update document title based on `route.meta.title` 5 | * @param {Object} to - VueRouter `to` route object 6 | * @returns {undefined} 7 | */ 8 | const updateDocumentTitle = (to) => { 9 | const title = to.meta.title; 10 | if (!title) return; 11 | 12 | document.title = title; 13 | }; 14 | 15 | /** 16 | * Publish afterEach happened event 17 | */ 18 | const publishEvent = () => { 19 | eventBus.publish('router:afterEach'); 20 | }; 21 | 22 | /** 23 | * afterEach middlewares call 24 | * @param {Object} to - VueRouter `to` route object 25 | * @param {Object} from - VueRouter `from` route object 26 | */ 27 | const afterEach = (to, from) => { 28 | updateDocumentTitle(to, from); 29 | publishEvent(to, from); 30 | }; 31 | 32 | export default afterEach; 33 | -------------------------------------------------------------------------------- /src/plug-in/axios/install.js: -------------------------------------------------------------------------------- 1 | import AxiosSingleton from './index'; 2 | 3 | import DefaultConfigBuilder from '@/plug-in/axios/default-config-builder'; 4 | import { 5 | requestMiddlewares, 6 | responseMiddlewares, 7 | } from '@/plug-in/axios/middlewares'; 8 | 9 | export default (vueInstance, config) => { 10 | const defaultConfig = new DefaultConfigBuilder(config).build(); 11 | const axios = AxiosSingleton.getInstance(defaultConfig); 12 | 13 | axios.interceptors.request.use( 14 | (request) => requestMiddlewares.onRequest(request, { vueInstance }), 15 | (error) => requestMiddlewares.onRequestError(error, { vueInstance }) 16 | ); 17 | 18 | axios.interceptors.response.use( 19 | (response) => responseMiddlewares.onResponse(response, { vueInstance }), 20 | (error) => responseMiddlewares.onResponseError(error, { vueInstance }) 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue3-boilerplate", 3 | "version": "1.0.0-alpha.55", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite", 7 | "devn": "vite --host", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore", 11 | "format": "prettier --write src/" 12 | }, 13 | "dependencies": { 14 | "axios": "^1.6.8", 15 | "pinia": "^2.1.3", 16 | "vee-validate": "^4.12.6", 17 | "vue": "^3.4.21", 18 | "vue-router": "^4.2.2" 19 | }, 20 | "devDependencies": { 21 | "@rushstack/eslint-patch": "^1.2.0", 22 | "@vitejs/plugin-vue": "^4.2.3", 23 | "@vue/eslint-config-prettier": "^7.1.0", 24 | "eslint": "^8.39.0", 25 | "eslint-plugin-vue": "^9.11.0", 26 | "prettier": "^2.8.8", 27 | "sass": "^1.64.1", 28 | "vite": "^4.3.9" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/composable/use-event-bus.composable.js: -------------------------------------------------------------------------------- 1 | import { onUnmounted } from 'vue'; 2 | import eventBus from '@/services/event-bus'; 3 | 4 | export const useEventBus = () => { 5 | const subscriptionMap = {}; 6 | 7 | const subscribeOn = (eventName, callback) => { 8 | const subscription = eventBus.subscribeOn(eventName, callback); 9 | subscriptionMap[subscription.id] = subscription.unsubscribe; 10 | 11 | return subscription; 12 | }; 13 | 14 | const publish = (eventName, ...args) => eventBus.publish(eventName, ...args); 15 | 16 | const unsubscribe = (subscriptionId) => { 17 | subscriptionMap[subscriptionId]?.(); 18 | subscriptionMap[subscriptionId] = null; 19 | }; 20 | 21 | onUnmounted(() => { 22 | Object.values(subscriptionMap).forEach((unSubscriber) => unSubscriber?.()); 23 | }); 24 | 25 | return { subscribeOn, publish, unsubscribe }; 26 | }; 27 | 28 | export default useEventBus; 29 | -------------------------------------------------------------------------------- /src/components/view/presentation/snackbar.component.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 17 | 18 | 37 | -------------------------------------------------------------------------------- /src/views/playground/playground-view.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 22 | 23 | 37 | -------------------------------------------------------------------------------- /src/plug-in/i18n/composable/use-locale.js: -------------------------------------------------------------------------------- 1 | import { computed, ref } from 'vue'; 2 | import { supportedLocaleList, rtlLocaleList } from '../dictionary'; 3 | 4 | const locale = ref(''); 5 | 6 | const useLocale = () => { 7 | const getLocale = computed(() => locale.value); 8 | 9 | const direction = computed(() => 10 | rtlLocaleList.includes(locale.value) ? 'rtl' : 'ltr' 11 | ); 12 | 13 | const setLocale = (newLocale) => { 14 | document.documentElement.setAttribute('lang', newLocale); 15 | 16 | const newDirection = rtlLocaleList.includes(newLocale) ? 'rtl' : 'ltr'; 17 | document.documentElement.setAttribute('dir', newDirection); 18 | 19 | if (locale.value === newLocale) return; 20 | 21 | if (!supportedLocaleList.includes(newLocale)) 22 | throw new Error(`Unsupported locale "${newLocale}"`); 23 | 24 | locale.value = newLocale; 25 | }; 26 | 27 | return { locale: getLocale, setLocale, supportedLocaleList, direction }; 28 | }; 29 | 30 | export default useLocale; 31 | -------------------------------------------------------------------------------- /src/components/view/presentation/switch.component.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 19 | 20 | 37 | -------------------------------------------------------------------------------- /src/components/common/layout-view/layout-view.component.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 32 | -------------------------------------------------------------------------------- /src/assets/scss/components/_side-sheet.scss: -------------------------------------------------------------------------------- 1 | @use '../abstracts/mixin' as *; 2 | 3 | $side-sheet: ( 4 | modal-side-sheet-color: var(--palette-surface-container-low), 5 | ); 6 | 7 | .side-sheet-enter-active, 8 | .side-sheet-leave-active { 9 | @at-root [dir='ltr'] & { 10 | transform-origin: right; 11 | } 12 | 13 | @at-root [dir='rtl'] & { 14 | transform-origin: left; 15 | } 16 | } 17 | 18 | .side-sheet-enter-active { 19 | @include transition(standard-decelerate) { 20 | transition-property: transform, opacity; 21 | } 22 | } 23 | 24 | .side-sheet-leave-active { 25 | @include transition(standard-accelerate) { 26 | transition-property: transform, opacity; 27 | } 28 | } 29 | 30 | .side-sheet-leave-to, 31 | .side-sheet-enter-from { 32 | opacity: 0; 33 | 34 | @at-root [dir='ltr'] & { 35 | transform: translateX(10%) scaleX(0); 36 | transform-origin: right; 37 | } 38 | 39 | @at-root [dir='rtl'] & { 40 | transform: translateX(-10%) scaleX(0); 41 | transform-origin: left; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/plug-in/axios/middlewares/on-request/index.js: -------------------------------------------------------------------------------- 1 | import responsibilities from './responsibilities'; 2 | 3 | /** 4 | * Response chain of responsibilities 5 | * @param {Object} response - AxiosResponse object 6 | * @param {Object} context 7 | * @param {Object} context.vueInstance - Current vue instance 8 | * @returns {any} - Modified response 9 | */ 10 | export const onResponse = (response, context) => { 11 | //TODO: Pass response to each response responsibility to modify (or not) 12 | return responsibilities.getRequestPayload(response, context); 13 | }; 14 | 15 | /** 16 | * Error chain of responsibilities 17 | * @param {Object} error - AxiosError object 18 | * @param {Object} context 19 | * @param {Object} context.vueInstance - Current vue instance 20 | * @returns {any} - Modified error 21 | */ 22 | export const onResponseError = (error, context) => { 23 | //TODO: Pass error to each error responsibility to modify (or not) 24 | return responsibilities.getErrorPayload(error, context); 25 | }; 26 | 27 | export default { 28 | onResponse, 29 | onResponseError, 30 | }; 31 | -------------------------------------------------------------------------------- /src/plug-in/axios/middlewares/on-response/index.js: -------------------------------------------------------------------------------- 1 | import responsibilities from './responsibilities'; 2 | 3 | /** 4 | * Response chain of responsibilities 5 | * @param {Object} response - AxiosResponse object 6 | * @param {Object} context 7 | * @param {Object} context.vueInstance - Current vue instance 8 | * @returns {any} - Modified response 9 | */ 10 | export const onResponse = (response, context) => { 11 | //TODO: Pass response to each response responsibility to modify (or not) 12 | return responsibilities.getResponsePayload(response, context); 13 | }; 14 | 15 | /** 16 | * Error chain of responsibilities 17 | * @param {Object} error - AxiosError object 18 | * @param {Object} context 19 | * @param {Object} context.vueInstance - Current vue instance 20 | * @returns {any} - Modified error 21 | */ 22 | export const onResponseError = (error, context) => { 23 | //TODO: Pass error to each error responsibility to modify (or not) 24 | return responsibilities.getErrorPayload(error, context); 25 | }; 26 | 27 | export default { 28 | onResponse, 29 | onResponseError, 30 | }; 31 | -------------------------------------------------------------------------------- /src/utils/timeout.util.js: -------------------------------------------------------------------------------- 1 | class Timeout { 2 | #timerId; 3 | #callback; 4 | #startTime; 5 | #remainingTime; 6 | #params; 7 | #delay; 8 | 9 | constructor(callback, delay, ...params) { 10 | this.#callback = callback; 11 | this.#params = params; 12 | this.#delay = delay; 13 | this._startTimer(); 14 | } 15 | 16 | _startTimer() { 17 | this.#startTime = Date.now(); 18 | this.#remainingTime = this.#delay; 19 | this.#timerId = setTimeout(this.#callback, this.#delay, ...this.#params); 20 | } 21 | 22 | pause() { 23 | if (!this.#timerId) return; 24 | 25 | this.cancel(); 26 | 27 | this.#remainingTime -= Date.now() - this.#startTime; 28 | } 29 | 30 | cancel() { 31 | clearTimeout(this.#timerId); 32 | this.#timerId = null; 33 | } 34 | 35 | resume() { 36 | if (this.#timerId) return; 37 | 38 | this.#startTime = Date.now(); 39 | this.#timerId = setTimeout( 40 | this.#callback, 41 | this.#remainingTime, 42 | ...this.#params 43 | ); 44 | } 45 | 46 | get remainingTime() { 47 | return this.#remainingTime; 48 | } 49 | } 50 | 51 | export default Timeout; 52 | -------------------------------------------------------------------------------- /src/components/common/picker/date/header.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 27 | 28 | 48 | -------------------------------------------------------------------------------- /src/components/view/presentation/radio-input.component.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 41 | -------------------------------------------------------------------------------- /src/plug-in/toast/components/toast-notification.component.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 32 | 33 | 47 | -------------------------------------------------------------------------------- /src/plug-in/axios/default-config-builder/index.js: -------------------------------------------------------------------------------- 1 | import Axios from 'axios'; 2 | 3 | class DefaultConfigBuilder { 4 | #defaultConfig = {}; 5 | /** 6 | * 7 | * @param {Object} config - axios config 8 | */ 9 | constructor(config) { 10 | this.#defaultConfig = { ...Axios.defaults, ...config }; 11 | } 12 | 13 | /** 14 | * Set common headers like `Cache-Control` 15 | */ 16 | #setCommonHeaders() { 17 | this.#defaultConfig.headers.common['Cache-Control'] = 'no-cache'; 18 | } 19 | 20 | /** 21 | * Set authorize headers like `token` or something. 22 | */ 23 | #setSystemAuthorizeHeaders() { 24 | //TODO: Authorize headers base on project 25 | } 26 | 27 | /** 28 | * Set request timeout duration 29 | */ 30 | #setTimeOutDuration() { 31 | const TWO_MINUTES = 2 * 60 * 1000; 32 | this.#defaultConfig.timeout = TWO_MINUTES; 33 | } 34 | 35 | /** 36 | * 37 | * @returns {Object} - default config 38 | */ 39 | build() { 40 | this.#setTimeOutDuration(); 41 | this.#setCommonHeaders(); 42 | this.#setSystemAuthorizeHeaders(); 43 | 44 | return this.#defaultConfig; 45 | } 46 | } 47 | 48 | export default DefaultConfigBuilder; 49 | -------------------------------------------------------------------------------- /src/plug-in/i18n/translate.js: -------------------------------------------------------------------------------- 1 | import { dictionaries } from './dictionary/index.js'; 2 | import { doubleBracketReplace } from './utils/double-bracket-replace.util.js'; 3 | 4 | /** 5 | * 6 | * @param {string} query - query to find in the dictionary 7 | * @param {Object} options 8 | * @param {string} options.locale - The locale 9 | * @param {Object} options.params - parameters to replace within the translation 10 | * @returns {string} - translation 11 | */ 12 | export const translate = (query, { params, locale }) => { 13 | const dictionary = dictionaries[locale]; 14 | if (!dictionary) throw new Error(`Unsupported locale "${locale}"`); 15 | 16 | try { 17 | const queryAsList = query.split('.'); 18 | const queryTranslation = queryAsList.reduce( 19 | (dictionaryChunk, queryChunk) => { 20 | return dictionaryChunk[queryChunk]; 21 | }, 22 | dictionary 23 | ); 24 | 25 | if (!queryTranslation) { 26 | throw new Error(`Undefined query "${query}"`); 27 | } 28 | 29 | const translation = doubleBracketReplace(queryTranslation, params); 30 | return translation; 31 | } catch (error) { 32 | console.error(error); 33 | } 34 | }; 35 | 36 | export default { translate }; 37 | -------------------------------------------------------------------------------- /src/assets/scss/abstracts/_elevation.scss: -------------------------------------------------------------------------------- 1 | $levels: 0, 1, 2, 3, 4, 6, 8, 12, 16, 24; 2 | 3 | $light-elevation-umbra-map: ( 4 | 0: 0px 0px 0px 0px, 5 | 1: 0px 2px 1px -1px, 6 | 2: 0px 3px 1px -2px, 7 | 3: 0px 3px 3px -2px, 8 | 4: 0px 2px 4px -1px, 9 | 6: 0px 3px 5px -1px, 10 | 8: 0px 5px 5px -3px, 11 | 12: 0px 7px 8px -4px, 12 | 16: 0px 8px 10px -5px, 13 | 24: 0px 11px 15px -7px, 14 | ); 15 | 16 | $light-elevation-penumbra-map: ( 17 | 0: 0px 0px 0px 0px, 18 | 1: 0px 1px 1px 0px, 19 | 2: 0px 2px 2px 0px, 20 | 3: 0px 3px 4px 0px, 21 | 4: 0px 4px 5px 0px, 22 | 6: 0px 6px 10px 0px, 23 | 8: 0px 8px 10px 1px, 24 | 12: 0px 12px 17px 2px, 25 | 16: 0px 16px 24px 2px, 26 | 24: 0px 24px 38px 3px, 27 | ); 28 | 29 | $light-elevation-ambient-map: ( 30 | 0: 0px 0px 0px 0px, 31 | 1: 0px 1px 3px 0px, 32 | 2: 0px 1px 5px 0px, 33 | 3: 0px 1px 8px 0px, 34 | 4: 0px 1px 10px 0px, 35 | 6: 0px 1px 18px 0px, 36 | 8: 0px 3px 14px 2px, 37 | 12: 0px 5px 22px 4px, 38 | 16: 0px 6px 30px 5px, 39 | 24: 0px 9px 46px 8px, 40 | ); 41 | 42 | $dark-elevation-opacity-map: ( 43 | 0: 0, 44 | 1: 0.05, 45 | 2: 0.07, 46 | 3: 0.08, 47 | 4: 0.09, 48 | 6: 0.11, 49 | 8: 0.12, 50 | 12: 0.14, 51 | 16: 0.15, 52 | 24: 0.16, 53 | ); 54 | -------------------------------------------------------------------------------- /src/components/view/presentation/progress-bar.component.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 25 | 26 | 46 | -------------------------------------------------------------------------------- /src/composable/use-promise.composable.js: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue'; 2 | 3 | /** 4 | * @typedef {Object} UsePromiseConfig 5 | * @property {boolean} [throwOnError=false] - Should throw error if happened 6 | */ 7 | const defaultConfig = { 8 | throwOnError: false, 9 | }; 10 | 11 | /** 12 | * usePromise composable 13 | * @param {Promise} promise - any function that returns a promise 14 | * @param {UsePromiseConfig} config - usePromise options 15 | * @returns {Object} - {data, error, loading, execute} 16 | */ 17 | export const usePromise = (promise, { throwOnError } = defaultConfig) => { 18 | const loading = ref(false); 19 | const error = ref(null); 20 | const data = ref(null); 21 | 22 | /** 23 | * Execute promise 24 | * @param {...any} params - params to call promise with them 25 | * @returns 26 | */ 27 | const execute = async (...params) => { 28 | if (loading.value) return; 29 | 30 | loading.value = true; 31 | error.value = null; 32 | 33 | try { 34 | data.value = await promise(...params); 35 | return data.value; 36 | } catch (errorDetails) { 37 | error.value = errorDetails; 38 | 39 | if (throwOnError) throw errorDetails; 40 | } finally { 41 | loading.value = false; 42 | } 43 | }; 44 | 45 | return { data, error, loading, execute }; 46 | }; 47 | 48 | export default usePromise; 49 | -------------------------------------------------------------------------------- /src/components/view/presentation/segmented-button.component.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 42 | 43 | 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vue 3 Boilerplate 2 | 3 | This template should help get you started developing with Vue 3 in Vite + Some general requirements. 4 | 5 | ## Extra Features 6 | 7 | - [x] configured file-base structure 8 | - [x] i18n plugin 9 | - [x] event bus 10 | - [x] some useful composable 11 | - [x] layouts and configured router with loading indicator 12 | - [x] general components 13 | 14 | ### Note 15 | 16 | > `utils` folder contains utility functions or helper classes that provide generic functionality not directly tied to specific services or features. 17 | 18 | > `services` folder contains classes or modules that encapsulate functionality related to specific services or features within the application. 19 | 20 | > `plug-in` folder contains self-contained and encapsulated functionalities in a way that a package does or extend Vue core functionalities by adding global methods, components, directives and so on. if a functionality already exists project wide like a util or something just use it to avoid duplication otherwise put it inside the plugin. 21 | 22 | ## [Github Repository](https://github.com/0Amirreza0/vue3-boilerplate) 23 | 24 | ## Project Setup 25 | 26 | ```sh 27 | npm install 28 | ``` 29 | 30 | ### Compile and Hot-Reload for Development 31 | 32 | ```sh 33 | npm run dev 34 | ``` 35 | 36 | ### Compile and Minify for Production 37 | 38 | ```sh 39 | npm run build 40 | ``` 41 | 42 | ### Lint with [ESLint](https://eslint.org/) 43 | 44 | ```sh 45 | npm run lint 46 | ``` 47 | -------------------------------------------------------------------------------- /src/assets/scss/themes/_dark-theme.scss: -------------------------------------------------------------------------------- 1 | @use '../abstracts/_colors' as *; 2 | 3 | $dark-theme: ( 4 | primary: map-get($primary, 80), 5 | on-primary: map-get($primary, 20), 6 | inverse-primary: map-get($primary, 40), 7 | primary-container: map-get($primary, 30), 8 | on-primary-container: map-get($primary, 90), 9 | secondary: map-get($secondary, 80), 10 | on-secondary: map-get($secondary, 20), 11 | secondary-container: map-get($secondary, 30), 12 | on-secondary-container: map-get($secondary, 90), 13 | tertiary: map-get($tertiary, 80), 14 | on-tertiary: map-get($tertiary, 20), 15 | tertiary-container: map-get($tertiary, 30), 16 | on-tertiary-container: map-get($tertiary, 90), 17 | error: map-get($error, 80), 18 | on-error: map-get($error, 20), 19 | error-container: map-get($error, 30), 20 | on-error-container: map-get($error, 90), 21 | surface-container-lowest: map-get($neutral, 5), 22 | surface-container-low: map-get($neutral, 10), 23 | surface-container: map-get($neutral, 15), 24 | surface-container-high: map-get($neutral, 20), 25 | // deprecated 26 | surface-container-highest: map-get($neutral, 25), 27 | inverse-surface: map-get($neutral, 90), 28 | inverse-on-surface: map-get($neutral, 20), 29 | surface-variant: map-get($neutral-variant, 30), 30 | on-surface-variant: map-get($neutral-variant, 80), 31 | on-surface: map-get($neutral, 90), 32 | outline: map-get($neutral-variant, 60), 33 | outline-variant: map-get($neutral-variant, 30), 34 | ); 35 | -------------------------------------------------------------------------------- /src/components/view/presentation/skeleton.component.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 37 | 38 | 62 | -------------------------------------------------------------------------------- /src/assets/scss/components/_date-picker.scss: -------------------------------------------------------------------------------- 1 | @use '../abstracts/mixin' as *; 2 | @use '../abstracts/variables' as *; 3 | 4 | .calendar-backward-leave-active, 5 | .calendar-backward-enter-active, 6 | .calendar-forward-leave-active, 7 | .calendar-forward-enter-active { 8 | transition: { 9 | property: transform, opacity; 10 | duration: map-get($duration, medium-2); 11 | timing-function: map-get($easing, linear); 12 | } 13 | } 14 | 15 | .calendar-backward-enter-from, 16 | .calendar-forward-leave-to { 17 | position: absolute; 18 | transform: translate(-100%); 19 | opacity: 0; 20 | } 21 | 22 | .calendar-backward-leave-to, 23 | .calendar-forward-enter-from { 24 | position: absolute; 25 | transform: translate(100%); 26 | opacity: 0; 27 | } 28 | 29 | .selected-date-leave-active, 30 | .selected-date-enter-active { 31 | transition: { 32 | property: transform, opacity; 33 | duration: map-get($duration, medium-2); 34 | timing-function: map-get($easing, easeOutExpo); 35 | } 36 | } 37 | 38 | .selected-date-leave-to { 39 | position: absolute; 40 | transform: translateY(100%); 41 | opacity: 0; 42 | } 43 | .selected-date-enter-from { 44 | position: absolute; 45 | transform: translateY(-100%); 46 | opacity: 0; 47 | } 48 | 49 | .year-picker-enter-active { 50 | @include transition(standard-decelerate) { 51 | transition-property: opacity, transform; 52 | } 53 | } 54 | 55 | .year-picker-leave-active { 56 | @include transition(standard-accelerate) { 57 | transition-property: opacity, transform; 58 | } 59 | } 60 | 61 | .year-picker-leave-to, 62 | .year-picker-enter-from { 63 | position: absolute; 64 | transform: translateY(-100%); 65 | opacity: 0; 66 | } 67 | -------------------------------------------------------------------------------- /src/components/view/presentation/stepper-input.component.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 42 | 43 | 67 | -------------------------------------------------------------------------------- /src/components/common/router/indicator.component.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 38 | 39 | 66 | -------------------------------------------------------------------------------- /src/components/common/scrim/container.component.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 65 | -------------------------------------------------------------------------------- /src/components/common/sheet/side/modal.component.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 44 | 45 | 73 | -------------------------------------------------------------------------------- /src/interfaces/calendar/calendar.interface.js: -------------------------------------------------------------------------------- 1 | class CalendarInterface { 2 | /** 3 | * Determine if a given year is a leap year. 4 | * @param {number} year - The year. 5 | * @returns {boolean} True if the year is a leap year, false otherwise. 6 | */ 7 | isLeapYear(year) { 8 | throw new Error('Must implement isLeapYear method'); 9 | } 10 | 11 | /** 12 | * Calculate the number of days in a given month and year. 13 | * @param {number} year - The year. 14 | * @param {number} month - The month (1-12). 15 | * @returns {number} The number of days in the month. 16 | * @throws {Error} If the month is not between 1 and 12. 17 | */ 18 | getDaysInMonth(year, month) { 19 | throw new Error('Must implement getDaysInMonthBy method'); 20 | } 21 | 22 | /** 23 | * Calculate the day of the given date using Zeller's Congruence. 24 | * @param {number} year - The year. 25 | * @param {number} month - The month (1-12). 26 | * @param {number} [day=1] - The day (1-31). 27 | * @returns {number} The day of the week (0-6) where 0 is Sunday, 1 is Monday, etc. 28 | * @throws {Error} If the month is not between 1 and 12. 29 | */ 30 | getDayOfWeek(year, month, day) { 31 | throw new Error('Must implement getFirstDayOfMonth method'); 32 | } 33 | 34 | /** 35 | * Get today's date in the Gregorian calendar. 36 | * @returns {Object} Today's date in the format '{year, month, date}'. 37 | */ 38 | get todayAsObject() { 39 | throw new Error('Must implement getToday method'); 40 | } 41 | 42 | /** 43 | * Get month list for gregorian calendar. 44 | * @returns {Array.} Month list ['January', ... , 'December']. 45 | */ 46 | get monthList() { 47 | throw new Error('Must monthList getter'); 48 | } 49 | 50 | /** 51 | * Get week days list. 52 | * @returns {Array.} Week days list ['Sunday', ... , 'Saturday']. 53 | */ 54 | get weekDayList() { 55 | throw new Error('Must weekDayList getter'); 56 | } 57 | } 58 | 59 | export default CalendarInterface; 60 | -------------------------------------------------------------------------------- /src/plug-in/i18n/composable/use-translate.js: -------------------------------------------------------------------------------- 1 | import { computed } from 'vue'; 2 | 3 | import { translate } from '../translate.js'; 4 | import useLocale from './use-locale.js'; 5 | 6 | const DEFAULT_CONFIG = Object.freeze({ 7 | asComputed: true, 8 | }); 9 | 10 | const useTranslate = (defaultConfig = DEFAULT_CONFIG) => { 11 | const { locale } = useLocale(); 12 | 13 | /** 14 | * Decide if it's a single query or an array of queries and call translate function 15 | * @param {(string|string[])} queries 16 | * @param {Object} options 17 | * @param {string} [options.locale=locale.value] 18 | * @param {Object} options.params - parameters to replace within the translation 19 | * @returns {(string|Object)} - object contains query string and translations as values 20 | */ 21 | const translateDirector = (queries, { locale, params }) => { 22 | if (typeof queries === 'string') { 23 | return translate(queries, { locale, params }); 24 | } 25 | 26 | if (Array.isArray(queries)) { 27 | return queryList.reduce((dictionary, query) => { 28 | dictionary[query] = translate(query, { locale }); 29 | return dictionary; 30 | }, {}); 31 | } 32 | }; 33 | 34 | /** 35 | * Apply configuration and call the translate director for translation 36 | * @param {(string|string[])} queries 37 | * @param {Object} params - parameters to replace within the translation 38 | * @param {Object} [config=defaultConfig] 39 | * @returns {(string|Object|import('vue').ComputedRef)} - object contains query string and translations as values 40 | */ 41 | const translator = (queries, params, config = defaultConfig) => { 42 | const { asComputed } = config; 43 | 44 | if (asComputed) { 45 | return computed(() => 46 | translateDirector(queries, { params, locale: locale.value }) 47 | ); 48 | } 49 | 50 | return translateDirector(queries, { params, locale: locale.value }); 51 | }; 52 | 53 | return { t: translator }; 54 | }; 55 | 56 | export default useTranslate; 57 | -------------------------------------------------------------------------------- /src/plug-in/vee-validate/composable/use-input.composable.js: -------------------------------------------------------------------------------- 1 | import { computed, toValue, watch, triggerRef } from 'vue'; 2 | import { useField } from 'vee-validate'; 3 | 4 | import Mask from '@/services/mask'; 5 | import { useTranslate } from '@/plug-in/i18n'; 6 | 7 | const { t: translator } = useTranslate({ asComputed: false }); 8 | 9 | const DEFAULT_OPTIONS = Object.freeze({ 10 | validationConfig: { 11 | validateOnMount: false, 12 | }, 13 | errorAsCode: false, 14 | maskPattern: '', 15 | }); 16 | 17 | const useInput = (name, rules, { strictRules = [], options = {} } = {}) => { 18 | const { errorAsCode, validationConfig, maskPattern, syncValue } = { 19 | ...DEFAULT_OPTIONS, 20 | ...options, 21 | }; 22 | 23 | const { value: fieldValue, errors: fieldErrors } = useField(name, rules, { 24 | ...DEFAULT_OPTIONS.validationConfig, 25 | ...(validationConfig ?? {}), 26 | }); 27 | 28 | const mask = new Mask(maskPattern); 29 | const value = computed({ 30 | get: () => mask.toMasked(fieldValue.value), 31 | set: (newValue) => { 32 | const unMaskedValue = mask.toUnMasked(newValue); 33 | const strictValue = strictRules.reduce( 34 | (strictValue, rule) => rule(strictValue), 35 | unMaskedValue 36 | ); 37 | 38 | fieldValue.value = strictValue; 39 | 40 | const hasValueChanged = unMaskedValue !== strictValue; 41 | if (hasValueChanged) triggerRef(value); 42 | }, 43 | }); 44 | 45 | if (syncValue) { 46 | watch( 47 | syncValue, 48 | () => { 49 | value.value = toValue(syncValue); 50 | }, 51 | { 52 | deep: true, 53 | immediate: true, 54 | } 55 | ); 56 | } 57 | 58 | const errors = computed(() => { 59 | if (errorAsCode) { 60 | return fieldErrors.value; 61 | } 62 | 63 | return fieldErrors.value.map(({ name, params }) => 64 | translator(`validationErrors.${name}`, params) 65 | ); 66 | }); 67 | 68 | const errorMessage = computed(() => errors.value[0]); 69 | 70 | return { value, fieldValue, errors, errorMessage }; 71 | }; 72 | 73 | export default useInput; 74 | -------------------------------------------------------------------------------- /src/assets/scss/themes/_light-theme.scss: -------------------------------------------------------------------------------- 1 | @use '../abstracts/_colors' as *; 2 | 3 | // NOTE: Deprecation flag 4 | // https://material.io/blog/tone-based-surface-color-m3 5 | // ---------------------------------------------------- 6 | // Surface Container Lowest is a new role 7 | // Surface at elevation +1 becomes Surface Container Low 8 | // Surface at elevation +2 becomes Surface Container 9 | // Surface at elevation +3 becomes Surface Container High 10 | // Surface at elevation +4 and +5 are being deprecated, it is recommended to use Surface Container Highest by default as a replacement. As an alternative Surface Container High, or Surface Dim can be used depending on the specific use case. 11 | // Surface Variant becomes Surface Container Highest 12 | 13 | $light-theme: ( 14 | primary: map-get($primary, 40), 15 | on-primary: map-get($primary, 100), 16 | inverse-primary: map-get($primary, 80), 17 | primary-container: map-get($primary, 90), 18 | on-primary-container: map-get($primary, 10), 19 | secondary: map-get($secondary, 40), 20 | on-secondary: map-get($secondary, 100), 21 | secondary-container: map-get($secondary, 90), 22 | on-secondary-container: map-get($secondary, 10), 23 | tertiary: map-get($tertiary, 40), 24 | on-tertiary: map-get($tertiary, 100), 25 | tertiary-container: map-get($tertiary, 90), 26 | on-tertiary-container: map-get($tertiary, 10), 27 | error: map-get($error, 40), 28 | on-error: map-get($error, 100), 29 | error-container: map-get($error, 90), 30 | on-error-container: map-get($error, 10), 31 | surface-container-lowest: map-get($neutral, 100), 32 | surface-container-low: map-get($neutral, 98), 33 | surface-container: map-get($neutral-variant, 95), 34 | surface-container-high: map-get($neutral-variant, 90), 35 | surface-container-highest: map-get($neutral-variant, 90), 36 | inverse-surface: map-get($neutral, 20), 37 | inverse-on-surface: map-get($neutral, 95), 38 | surface-variant: map-get($neutral-variant, 90), 39 | on-surface-variant: map-get($neutral-variant, 30), 40 | on-surface: map-get($neutral, 10), 41 | outline: map-get($neutral-variant, 50), 42 | outline-variant: map-get($neutral-variant, 80), 43 | ); 44 | -------------------------------------------------------------------------------- /src/components/index.js: -------------------------------------------------------------------------------- 1 | const componentSourceMap = import.meta.globEager( 2 | ['@/components/common/**/*.component.vue'], 3 | { import: 'default' } 4 | ); 5 | 6 | const componentSourceList = Object.keys(componentSourceMap); 7 | 8 | /** 9 | * Determine the component name by it's source path 10 | * @param {string} source 11 | * @param {Object} config 12 | * @param {Object} [config.direction="backward"] - (forward) root-(nestedDirectory)*-fileName | (backward) fileName-(nestedDirectory)*-root 13 | * @returns {string} - component name from source using direction 14 | */ 15 | const getComponentNameByPath = (source, { direction = 'backward' }) => { 16 | const sourceSegments = source.split('/'); 17 | const fileName = sourceSegments.pop().replace(/\.(.*)/, ''); 18 | 19 | const isBackward = direction === 'backward'; 20 | 21 | if (isBackward) { 22 | sourceSegments.reverse(); 23 | } 24 | 25 | const kebabCaseDirectories = sourceSegments.join('-'); 26 | 27 | if (kebabCaseDirectories === fileName) { 28 | return fileName; 29 | } 30 | 31 | const prefix = isBackward ? `${fileName}-` : ''; 32 | const postfix = !isBackward ? `-${fileName}` : ''; 33 | 34 | const componentName = `${prefix}${kebabCaseDirectories}${postfix}`; 35 | return componentName; 36 | }; 37 | 38 | /** 39 | * Register all the components inside the src/components/common/* no mater how deep if they follow `*.component.vue` 40 | * @param {Object} vueInstance - the app created by createVueApp 41 | * @param {Array.} [reversedNamePaths] - a path nested in /common/ to reverse travel the path 42 | */ 43 | export const registerCommonComponents = ( 44 | vueInstance, 45 | reversedNamePaths = [] 46 | ) => { 47 | componentSourceList.forEach((sourceFromRoot) => { 48 | const source = sourceFromRoot.split('/common/')[1]; 49 | const component = componentSourceMap[sourceFromRoot]; 50 | 51 | const shouldReverseName = reversedNamePaths.some((path) => 52 | source.startsWith(path) 53 | ); 54 | 55 | const direction = shouldReverseName ? 'forward' : 'backward'; 56 | const componentName = getComponentNameByPath(source, { direction }); 57 | 58 | vueInstance.component(componentName, component); 59 | }); 60 | }; 61 | -------------------------------------------------------------------------------- /src/components/common/picker/date/control-menu.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 58 | 59 | 82 | -------------------------------------------------------------------------------- /src/components/common/skeleton/base.component.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 57 | 58 | 99 | -------------------------------------------------------------------------------- /src/assets/scss/abstracts/_colors.scss: -------------------------------------------------------------------------------- 1 | $primary: ( 2 | 0: #000000, 3 | 10: #022100, 4 | 20: #053900, 5 | 25: #0a4601, 6 | 30: #19520d, 7 | 35: #265e19, 8 | 40: #326b24, 9 | 50: #4a843a, 10 | 60: #649f51, 11 | 70: #7dbb69, 12 | 80: #98d782, 13 | 90: #b3f39c, 14 | 95: #cbffb6, 15 | 98: #edffe1, 16 | 99: #f7ffee, 17 | 100: #ffffff, 18 | ); 19 | 20 | $secondary: ( 21 | 0: #000000, 22 | 10: #121f0e, 23 | 20: #273421, 24 | 25: #323f2c, 25 | 30: #3d4b37, 26 | 35: #485642, 27 | 40: #54624d, 28 | 50: #6c7b65, 29 | 60: #86957d, 30 | 70: #a0b097, 31 | 80: #bbcbb1, 32 | 90: #d7e8cc, 33 | 95: #e6f6da, 34 | 98: #eeffe2, 35 | 99: #f7ffee, 36 | 100: #ffffff, 37 | ); 38 | 39 | $tertiary: ( 40 | 0: #000000, 41 | 10: #002021, 42 | 20: #003739, 43 | 25: #0f4244, 44 | 30: #1e4e50, 45 | 35: #2b595c, 46 | 40: #386668, 47 | 50: #517f81, 48 | 60: #6b999b, 49 | 70: #85b3b6, 50 | 80: #a0cfd1, 51 | 90: #bcebee, 52 | 95: #cafafc, 53 | 98: #e6feff, 54 | 99: #f2ffff, 55 | 100: #ffffff, 56 | ); 57 | 58 | $error: ( 59 | 0: #000000, 60 | 10: #410002, 61 | 20: #690005, 62 | 25: #7e0007, 63 | 30: #93000a, 64 | 35: #a80710, 65 | 40: #ba1a1a, 66 | 50: #de3730, 67 | 60: #ff5449, 68 | 70: #ff897d, 69 | 80: #ffb4ab, 70 | 90: #ffdad6, 71 | 95: #ffedea, 72 | 98: #fff8f7, 73 | 99: #fffbff, 74 | 100: #ffffff, 75 | ); 76 | 77 | $neutral: ( 78 | 0: #000000, 79 | 5: #10110e, 80 | 10: #1a1c18, 81 | 15: #252623, 82 | 20: #2f312d, 83 | 25: #3a3c38, 84 | 30: #454743, 85 | 35: #51534e, 86 | 40: #5d5f5a, 87 | 50: #767872, 88 | 60: #90918c, 89 | 70: #abaca6, 90 | 80: #c6c7c1, 91 | 90: #e2e3dc, 92 | 95: #f1f1ea, 93 | 98: #fafaf3, 94 | 99: #fcfdf6, 95 | 100: #ffffff, 96 | ); 97 | 98 | $neutral-variant: ( 99 | 0: #000000, 100 | 5: #0e120c, 101 | 10: #181d15, 102 | 15: #222720, 103 | 20: #2c3229, 104 | 25: #373d34, 105 | 30: #43483f, 106 | 35: #4e544a, 107 | 40: #5a6056, 108 | 50: #73796e, 109 | 60: #8d9387, 110 | 70: #a7ada1, 111 | 80: #c3c8bc, 112 | 90: #dfe4d7, 113 | 95: #edf3e5, 114 | 98: #f6fbee, 115 | 99: #f9fef1, 116 | 100: #ffffff, 117 | ); 118 | -------------------------------------------------------------------------------- /src/components/common/snack-bar/snack-bar.component.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 44 | 45 | 91 | -------------------------------------------------------------------------------- /src/components/common/icon/base.component.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 68 | 69 | 90 | -------------------------------------------------------------------------------- /src/plug-in/toast/components/toast-manager.component.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 71 | 72 | 104 | -------------------------------------------------------------------------------- /src/assets/scss/abstracts/_typography.scss: -------------------------------------------------------------------------------- 1 | $typography: ( 2 | default: ( 3 | display-large: ( 4 | font-size: 3.56rem, 5 | font-weight: 400, 6 | line-height: 4rem, 7 | letter-spacing: -0.015rem, 8 | ), 9 | display-medium: ( 10 | font-size: 2.8rem, 11 | font-weight: 400, 12 | line-height: 3.25rem, 13 | letter-spacing: 0, 14 | ), 15 | display-small: ( 16 | font-size: 2.25rem, 17 | font-weight: 400, 18 | line-height: 2.75rem, 19 | letter-spacing: 0, 20 | ), 21 | headline-large: ( 22 | font-size: 2rem, 23 | font-weight: 400, 24 | line-height: 2.5rem, 25 | letter-spacing: 0, 26 | ), 27 | headline-medium: ( 28 | font-size: 1.75rem, 29 | font-weight: 400, 30 | line-height: 2.25rem, 31 | letter-spacing: 0, 32 | ), 33 | headline-small: ( 34 | font-size: 1.5rem, 35 | font-weight: 400, 36 | line-height: 2rem, 37 | letter-spacing: 0, 38 | ), 39 | title-large: ( 40 | font-size: 1.375rem, 41 | font-weight: 400, 42 | line-height: 1.75rem, 43 | letter-spacing: 0, 44 | ), 45 | title-medium: ( 46 | font-size: 1rem, 47 | font-weight: 400, 48 | line-height: 1.5rem, 49 | letter-spacing: 0.009rem, 50 | ), 51 | title-small: ( 52 | font-size: 0.875rem, 53 | font-weight: 500, 54 | line-height: 1.25rem, 55 | letter-spacing: 0, 56 | ), 57 | label-large: ( 58 | font-size: 0.875rem, 59 | font-weight: 500, 60 | line-height: 1.25rem, 61 | letter-spacing: 0.00625rem, 62 | ), 63 | label-medium: ( 64 | font-size: 0.75rem, 65 | font-weight: 400, 66 | line-height: 1rem, 67 | letter-spacing: 0.03125rem, 68 | ), 69 | label-small: ( 70 | font-size: 0.6875rem, 71 | font-weight: 500, 72 | line-height: 1rem, 73 | letter-spacing: 0.03125rem, 74 | ), 75 | label-xsmall: ( 76 | font-size: 0.5rem, 77 | font-weight: 500, 78 | line-height: 0.625rem, 79 | letter-spacing: 0.03125rem, 80 | ), 81 | body-large: ( 82 | font-size: 1rem, 83 | font-weight: 400, 84 | line-height: 1.5rem, 85 | letter-spacing: 0.03125rem, 86 | ), 87 | body-medium: ( 88 | font-size: 0.875rem, 89 | font-weight: 400, 90 | line-height: 1.25rem, 91 | letter-spacing: 0.015625rem, 92 | ), 93 | body-small: ( 94 | font-size: 0.75rem, 95 | font-weight: 400, 96 | line-height: 1rem, 97 | letter-spacing: 0.025rem, 98 | ), 99 | ), 100 | ); 101 | -------------------------------------------------------------------------------- /src/views/presentation/presentation-view.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 33 | 34 | 53 | -------------------------------------------------------------------------------- /src/assets/scss/abstracts/_variables.scss: -------------------------------------------------------------------------------- 1 | $base-space: 4px; 2 | 3 | $elevation-base-color: ( 4 | light: #000000, 5 | dark: #000000, 6 | ); 7 | 8 | $easing: ( 9 | emphasized: cubic-bezier(0.2, 0, 0, 1), 10 | emphasized-decelerate: cubic-bezier(0.05, 0.7, 0.1, 1), 11 | emphasized-accelerate: cubic-bezier(0.3, 0, 0.8, 0.15), 12 | standard: cubic-bezier(0.2, 0, 0, 1), 13 | standard-decelerate: cubic-bezier(0, 0, 0, 1), 14 | standard-accelerate: cubic-bezier(0.3, 0, 1, 1), 15 | ease-in-out-quint: cubic-bezier(0.83, 0, 0.17, 1), 16 | ease-out: ease-out, 17 | linear: linear, 18 | easeInSine: cubic-bezier(0.12, 0, 0.39, 0), 19 | easeOutSine: cubic-bezier(0.61, 1, 0.88, 1), 20 | easeInOutSine: cubic-bezier(0.37, 0, 0.63, 1), 21 | easeInQuad: cubic-bezier(0.11, 0, 0.5, 0), 22 | easeOutQuad: cubic-bezier(0.5, 1, 0.89, 1), 23 | easeInOutQuad: cubic-bezier(0.45, 0, 0.55, 1), 24 | easeInCubic: cubic-bezier(0.32, 0, 0.67, 0), 25 | easeOutCubic: cubic-bezier(0.33, 1, 0.68, 1), 26 | easeInOutCubic: cubic-bezier(0.65, 0, 0.35, 1), 27 | easeInQuart: cubic-bezier(0.5, 0, 0.75, 0), 28 | easeOutQuart: cubic-bezier(0.25, 1, 0.5, 1), 29 | easeInOutQuart: cubic-bezier(0.76, 0, 0.24, 1), 30 | easeInQuint: cubic-bezier(0.64, 0, 0.78, 0), 31 | easeOutQuint: cubic-bezier(0.22, 1, 0.36, 1), 32 | easeInOutQuint: cubic-bezier(0.83, 0, 0.17, 1), 33 | easeInExpo: cubic-bezier(0.7, 0, 0.84, 0), 34 | easeOutExpo: cubic-bezier(0.16, 1, 0.3, 1), 35 | easeInOutExpo: cubic-bezier(0.87, 0, 0.13, 1), 36 | easeInCirc: cubic-bezier(0.55, 0, 1, 0.45), 37 | easeOutCirc: cubic-bezier(0, 0.55, 0.45, 1), 38 | easeInOutCirc: cubic-bezier(0.85, 0, 0.15, 1), 39 | easeInBack: cubic-bezier(0.36, 0, 0.66, -0.56), 40 | easeOutBack: cubic-bezier(0.34, 1.56, 0.64, 1), 41 | easeInOutBack: cubic-bezier(0.68, -0.6, 0.32, 1.6), 42 | ); 43 | 44 | $duration: ( 45 | short-1: 50ms, 46 | short-2: 100ms, 47 | short-3: 150ms, 48 | short-4: 200ms, 49 | medium-1: 250ms, 50 | medium-2: 300ms, 51 | medium-3: 350ms, 52 | medium-4: 400ms, 53 | long-1: 450ms, 54 | long-2: 500ms, 55 | long-3: 550ms, 56 | long-4: 600ms, 57 | extra-long-1: 700ms, 58 | extra-long-2: 800ms, 59 | extra-long-3: 900ms, 60 | extra-long-4: 1000ms, 61 | ); 62 | 63 | $easing-duration-pair: ( 64 | // Exit the screen temporarily 65 | emphasized: map-get($duration, 'long-2'), 66 | // Enter the screen 67 | emphasized-decelerate: map-get($duration, 'medium-4'), 68 | // Exit the screen permanently 69 | emphasized-accelerate: map-get($duration, 'short-4'), 70 | // Exit the screen temporarily 71 | standard: map-get($duration, 'medium-2'), 72 | // Enter the screen 73 | standard-decelerate: map-get($duration, 'medium-1'), 74 | // Exit the screen permanently 75 | standard-accelerate: map-get($duration, 'short-4') 76 | ); 77 | -------------------------------------------------------------------------------- /src/components/view/presentation/icon-button.component.vue: -------------------------------------------------------------------------------- 1 | 80 | 81 | 95 | -------------------------------------------------------------------------------- /src/components/view/presentation/filter-chip.component.vue: -------------------------------------------------------------------------------- 1 | 73 | 74 | 79 | 80 | 98 | -------------------------------------------------------------------------------- /src/plug-in/toast/toast-manager.js: -------------------------------------------------------------------------------- 1 | import { reactive } from 'vue'; 2 | import Timeout from '@/utils/timeout.util.js'; 3 | import { 4 | TOAST_POSITIONS, 5 | DEFAULT_CONFIG, 6 | STACK_MAP, 7 | } from './constants/toast.constant.js'; 8 | 9 | export const stacks = reactive(STACK_MAP); 10 | 11 | class ToastManager { 12 | #config; 13 | #toastsTimer = {}; 14 | #reserveStacks = TOAST_POSITIONS.reduce((stackMap, position) => { 15 | stackMap[position] = []; 16 | return stackMap; 17 | }, {}); 18 | 19 | constructor(config = {}) { 20 | this.#config = { ...DEFAULT_CONFIG, ...config }; 21 | } 22 | 23 | #getToastConfig(toastConfig) { 24 | const config = { ...this.#config, ...toastConfig }; 25 | 26 | return { 27 | duration: config.duration, 28 | position: config.position, 29 | type: config.type, 30 | autoDismiss: config.autoDismiss, 31 | }; 32 | } 33 | 34 | #checkReserveStack({ position }) { 35 | const reserveStack = this.#reserveStacks[position]; 36 | const hasFreeSpace = stacks[position].length < this.#config.stackMaxToast; 37 | 38 | if (!hasFreeSpace || !reserveStack.length) return; 39 | 40 | const reservedToast = reserveStack.pop(); 41 | this.show(reservedToast.props, reservedToast.toastConfig); 42 | } 43 | 44 | dismiss(toast) { 45 | const stack = stacks[toast.position]; 46 | 47 | const toastIndex = stack.findIndex(({ id }) => toast.id === id); 48 | stack.splice(toastIndex, 1); 49 | 50 | this.#toastsTimer[toast.id]?.cancel(); 51 | this.#checkReserveStack(toast); 52 | } 53 | 54 | pauseTimer(toastId) { 55 | this.#toastsTimer[toastId]?.pause(); 56 | } 57 | 58 | resumeTimer(toastId) { 59 | this.#toastsTimer[toastId]?.resume(); 60 | } 61 | 62 | show(props, toastConfig = {}) { 63 | const config = this.#getToastConfig(toastConfig); 64 | const { duration, position, autoDismiss } = config; 65 | 66 | const stackItemCount = stacks[position].length; 67 | if (stackItemCount >= this.#config.stackMaxToast) { 68 | this.#reserveStacks[position].unshift({ toastConfig, props }); 69 | return; 70 | } 71 | 72 | if (!TOAST_POSITIONS.includes(position)) 73 | throw new Error(`Unsupported toast position: ${position}`); 74 | 75 | const id = Symbol(); 76 | 77 | if (autoDismiss) { 78 | const timeout = new Timeout(() => this.dismiss(toast), duration); 79 | this.#toastsTimer[id] = timeout; 80 | } 81 | 82 | const timer = this.#toastsTimer[id]; 83 | 84 | const toast = { 85 | id, 86 | props, 87 | ...config, 88 | get remainingTime() { 89 | return timer?.remainingTime; 90 | }, 91 | }; 92 | 93 | const addStrategy = position.includes('top') ? 'push' : 'unshift'; 94 | stacks[position][addStrategy](toast); 95 | 96 | return toast; 97 | } 98 | } 99 | 100 | export default ToastManager; 101 | -------------------------------------------------------------------------------- /src/components/common/picker/date/year-picker.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 78 | 79 | 123 | -------------------------------------------------------------------------------- /src/components/common/sheet/side/standard.component.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 61 | 62 | 130 | -------------------------------------------------------------------------------- /src/components/view/presentation/button.component.vue: -------------------------------------------------------------------------------- 1 | 89 | 90 | 95 | 96 | 110 | -------------------------------------------------------------------------------- /src/components/common/input/radio.component.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 35 | 36 | 141 | -------------------------------------------------------------------------------- /src/services/calendar/gregorian/gregorian-calendar.service.js: -------------------------------------------------------------------------------- 1 | import CalendarInterface from '@/interfaces/calendar/calendar.interface.js'; 2 | 3 | class GregorianCalendar extends CalendarInterface { 4 | #todayDate; 5 | #monthList; 6 | #weekDayList; 7 | 8 | constructor({ monthList, weekDayList }) { 9 | super(); 10 | 11 | const today = new Date(); 12 | const year = today.getFullYear(); 13 | const month = today.getMonth() + 1; 14 | const day = today.getDate(); 15 | 16 | this.#todayDate = { year, month, day }; 17 | this.#monthList = monthList; 18 | this.#weekDayList = weekDayList; 19 | } 20 | 21 | /** 22 | * Determine if a given year is a leap year. 23 | * @param {number} year - The year. 24 | * @returns {boolean} True if the year is a leap year, false otherwise. 25 | */ 26 | isLeapYear(year) { 27 | if (year % 4 === 0) { 28 | if (year % 100 === 0) { 29 | return year % 400 === 0; 30 | } 31 | 32 | return true; 33 | } 34 | 35 | return false; 36 | } 37 | 38 | /** 39 | * Calculate the number of days in a given month and year. 40 | * @param {number} year - The year. 41 | * @param {number} month - The month (1-12). 42 | * @returns {number} The number of days in the month. 43 | * @throws {Error} If the month is not between 1 and 12. 44 | */ 45 | getDaysInMonth(year, month) { 46 | if (month < 1 || month > 12) { 47 | throw new Error('Month must be between 1 and 12.'); 48 | } 49 | 50 | const daysInMonthMap = [31, -1, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; 51 | 52 | if (month === 2) { 53 | return this.isLeapYear(year) ? 29 : 28; 54 | } 55 | 56 | return daysInMonthMap[month - 1]; 57 | } 58 | 59 | /** 60 | * Calculate the day of the given date using Zeller's Congruence. 61 | * @param {number} year - The year. 62 | * @param {number} month - The month (1-12). 63 | * @param {number} [day=1] - The day (1-31). 64 | * @returns {number} The day of the week (0-6) where 0 is Sunday, 1 is Monday, etc. 65 | * @throws {Error} If the month is not between 1 and 12. 66 | */ 67 | getDayOfWeek(year, month, day = 1) { 68 | if (month < 1 || month > 12) { 69 | throw new Error('Month must be between 1 and 12.'); 70 | } 71 | 72 | if (month < 3) { 73 | month += 12; 74 | year -= 1; 75 | } 76 | 77 | const adjustedMonth = month; 78 | const yearOfCentury = year % 100; 79 | const zeroBasedCentury = Math.floor(year / 100); 80 | 81 | const dayOfWeekIndex = 82 | (day + 83 | Math.floor((13 * (adjustedMonth + 1)) / 5) + 84 | yearOfCentury + 85 | Math.floor(yearOfCentury / 4) + 86 | Math.floor(zeroBasedCentury / 4) + 87 | 5 * zeroBasedCentury) % 88 | 7; 89 | 90 | const dayOfWeek = (dayOfWeekIndex + 6) % 7; 91 | 92 | return dayOfWeek; 93 | } 94 | 95 | /** 96 | * Get today's date in the Gregorian calendar. 97 | * @returns {Object} Today's date in the format '{year, month, date}'. 98 | */ 99 | get todayAsObject() { 100 | return this.#todayDate; 101 | } 102 | 103 | /** 104 | * Get month list for gregorian calendar. 105 | * @returns {Array.} Month list ['January', ... , 'December']. 106 | */ 107 | get monthList() { 108 | return this.#monthList; 109 | } 110 | 111 | /** 112 | * Get week days list. 113 | * @returns {Array.} Week days list ['Sunday', ... , 'Saturday']. 114 | */ 115 | get weekDayList() { 116 | return this.#weekDayList; 117 | } 118 | } 119 | 120 | export default GregorianCalendar; 121 | -------------------------------------------------------------------------------- /src/assets/scss/abstracts/_mixin.scss: -------------------------------------------------------------------------------- 1 | @use './typography' as *; 2 | @use './breakpoints' as *; 3 | @use './elevation' as *; 4 | @use './variables' as *; 5 | 6 | @mixin media-query($screen-size) { 7 | $size: map-get($breakpoints, $screen-size); 8 | 9 | @if $size { 10 | // Desktop first approach 11 | // TODO: Implement configurable approach 12 | @media only screen and (max-width: $size) { 13 | @content; 14 | } 15 | } 16 | } 17 | 18 | @mixin typography($name) { 19 | $target: map-get($typography, default); 20 | $level: map-get($target, $name); 21 | 22 | font-size: map-get($level, font-size); 23 | font-weight: map-get($level, font-weight); 24 | line-height: map-get($level, line-height); 25 | letter-spacing: map-get($level, letter-spacing); 26 | 27 | @each $point, $value in $breakpoints { 28 | $target: map-get($typography, $point); 29 | 30 | @if $target { 31 | @include media-query($point) { 32 | $level: map-get($target, $name); 33 | 34 | @if $level { 35 | font-size: map-get($level, font-size); 36 | font-weight: map-get($level, font-weight); 37 | line-height: map-get($level, line-height); 38 | letter-spacing: map-get($level, letter-spacing); 39 | } 40 | } 41 | } 42 | } 43 | } 44 | 45 | @mixin flex( 46 | $dir: row, 47 | $wrap: nowrap, 48 | $align: flex-start, 49 | $justify: flex-start 50 | ) { 51 | display: flex; 52 | flex-direction: $dir; 53 | flex-wrap: $wrap; 54 | align-items: $align; 55 | justify-content: $justify; 56 | } 57 | 58 | @mixin shadow-elevation( 59 | $level, 60 | $base-color: map-get($elevation-base-color, light) 61 | ) { 62 | @if ($level == 0) { 63 | box-shadow: none; 64 | } @else { 65 | $umbra: rgba($base-color, 0.2); 66 | $penumbra: rgba($base-color, 0.14); 67 | $ambient: rgba($base-color, 0.12); 68 | 69 | box-shadow: map-get($light-elevation-umbra-map, $level) $umbra, 70 | map-get($light-elevation-penumbra-map, $level) $penumbra, 71 | map-get($light-elevation-ambient-map, $level) $ambient; 72 | } 73 | } 74 | 75 | @mixin elevation( 76 | $level: 1, 77 | $base-light-color: map-get($elevation-base-color, light), 78 | $base-dark-color: map-get($elevation-base-color, dark) 79 | ) { 80 | @at-root [data-theme='light'] & { 81 | @include shadow-elevation($level, $base-light-color); 82 | } 83 | 84 | @at-root [data-theme='dark'] & { 85 | @include shadow-elevation($level, $base-dark-color); 86 | } 87 | } 88 | 89 | @mixin transition($easing-token: standard) { 90 | transition-duration: map-get($easing-duration-pair, $easing-token); 91 | transition-timing-function: map-get($easing, $easing-token); 92 | @content; 93 | } 94 | 95 | @mixin state-layer( 96 | $hover-color: transparent, 97 | $active-color: '', 98 | $events: false, 99 | $element: false 100 | ) { 101 | @if ($active-color == '') { 102 | $active-color: $hover-color; 103 | } 104 | 105 | @if $element == true { 106 | &::before { 107 | content: ''; 108 | position: absolute; 109 | width: 100%; 110 | height: 100%; 111 | top: 0; 112 | left: 0; 113 | opacity: 0; 114 | 115 | transition: { 116 | property: background-color, opacity; 117 | duration: map-get($duration, short-3); 118 | timing-function: map-get($easing, linear); 119 | } 120 | } 121 | } 122 | 123 | @if $events == true { 124 | &:hover::before { 125 | opacity: 0.08; 126 | background-color: $hover-color; 127 | } 128 | 129 | &:active::before { 130 | opacity: 0.1; 131 | background-color: $active-color; 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/services/event-bus/event-bus.service.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @callback SubscriptionCallback 3 | * @param {...*} args 4 | */ 5 | 6 | /** 7 | * @typedef {Object} Subscriber 8 | * @property {string} id 9 | * @property {?SubscriptionCallback} callback 10 | * @property {?Function} unsubscribe 11 | */ 12 | 13 | /** 14 | * @typedef {Object} GetSubscriberMapOptions 15 | * @param {boolean} [createIfAbsent=false] 16 | */ 17 | 18 | import deepClone from '@/utils/deep-clone.util.js'; 19 | 20 | class EventBus { 21 | #eventsSubscriberMap; 22 | 23 | /** 24 | * Create a new EventBus 25 | */ 26 | constructor() { 27 | this.#eventsSubscriberMap = {}; 28 | } 29 | 30 | /** 31 | * @param {string} eventName 32 | * @param { GetSubscriberMapOptions } options 33 | * @returns {?Object.} Subscriber map for the event 34 | */ 35 | _getEventSubscriberMap(eventName, { createIfAbsent = false } = {}) { 36 | if (!eventName) throw new Error('eventName is required'); 37 | 38 | const subscriptionMap = this.#eventsSubscriberMap[eventName]; 39 | 40 | if (!subscriptionMap && createIfAbsent) 41 | this.#eventsSubscriberMap[eventName] = {}; 42 | 43 | return this.#eventsSubscriberMap[eventName]; 44 | } 45 | 46 | /** 47 | * 48 | * @param {string} eventName 49 | * @returns {?Array.} List of subscribers 50 | */ 51 | _getEventSubscribersAsList = (eventName) => { 52 | const subscriberMap = this._getEventSubscriberMap(eventName); 53 | if (!subscriberMap) return; 54 | 55 | const subscriberIdList = Object.getOwnPropertySymbols(subscriberMap); 56 | 57 | return subscriberIdList.map((subscriberId) => ({ 58 | callback: subscriberMap[subscriberId], 59 | id: subscriberId, 60 | })); 61 | }; 62 | 63 | /** 64 | * 65 | * @param {string} eventName 66 | * @returns {?Object.} Subscriber map for the event 67 | */ 68 | _garbageCollector(eventName) { 69 | const subscriberList = this._getEventSubscribersAsList(eventName); 70 | if (!subscriberList) return; 71 | 72 | const subscriberMap = {}; 73 | let isGarbageEvent = true; 74 | 75 | subscriberList.forEach((subscriber) => { 76 | const { id, callback } = subscriber; 77 | const hasCallback = !!callback; 78 | 79 | if (!hasCallback) return; 80 | 81 | subscriberMap[id] = callback; 82 | isGarbageEvent = false; 83 | }); 84 | 85 | if (!isGarbageEvent) 86 | return (this.#eventsSubscriberMap[eventName] = subscriberMap); 87 | 88 | const { [eventName]: garbageEvent, ...otherEvents } = 89 | this.#eventsSubscriberMap; 90 | 91 | this.#eventsSubscriberMap = otherEvents; 92 | } 93 | 94 | /** 95 | * 96 | * @param {string} eventName 97 | * @param {Symbol} subscriptionId 98 | */ 99 | _unsubscribe(eventName, subscriptionId) { 100 | const subscriberMap = this._getEventSubscriberMap(eventName); 101 | 102 | subscriberMap[subscriptionId] = null; 103 | this._garbageCollector(eventName); 104 | } 105 | 106 | /** 107 | * 108 | * @param {string} eventName 109 | * @param {Function} callback 110 | * @returns {Subscriber} A new subscriber 111 | */ 112 | subscribeOn(eventName, callback) { 113 | const subscriberMap = this._getEventSubscriberMap(eventName, { 114 | createIfAbsent: true, 115 | }); 116 | const subscriptionId = Symbol(eventName); 117 | 118 | subscriberMap[subscriptionId] = callback; 119 | 120 | return { 121 | id: subscriptionId, 122 | unsubscribe: () => this._unsubscribe(eventName, subscriptionId), 123 | }; 124 | } 125 | 126 | // TODO: subscribeOnceOn 127 | 128 | /** 129 | * 130 | * @param {string} eventName 131 | * @param {...any} args 132 | */ 133 | publish(eventName, ...args) { 134 | const subscriberList = this._getEventSubscribersAsList(eventName) ?? []; 135 | 136 | subscriberList.forEach((subscriber) => { 137 | const subscriptionArguments = deepClone(args); 138 | subscriber.callback(...subscriptionArguments); 139 | }); 140 | } 141 | } 142 | 143 | export default EventBus; 144 | -------------------------------------------------------------------------------- /src/components/common/progress-indicator/linear.component.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 57 | 58 | 160 | -------------------------------------------------------------------------------- /src/services/mask/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | REGEX_TOKEN, 3 | REGEX_STARTING_CHAR, 4 | REGEX_ENDING_CHAR, 5 | DEFAULT_TOKEN, 6 | } from '@/constants/mask/mask.constant.js'; 7 | 8 | class Mask { 9 | #patternCharList; 10 | #pattern; 11 | #token; 12 | 13 | /** 14 | * Return array of mapped pattern characters 15 | * @param {string} pattern 16 | * @returns {Array.} - Array of mapped pattern characters 17 | */ 18 | 19 | #getPatternList(pattern) { 20 | const token = this.#token; 21 | const namedPatterns = { 22 | time: `${token}${token}:${token}${token}`, 23 | }; 24 | const mappedPattern = namedPatterns[pattern] || pattern; 25 | 26 | return mappedPattern.split(''); 27 | } 28 | 29 | /** 30 | * @param {string} pattern 31 | * @param {string} token 32 | * @constructor 33 | */ 34 | 35 | constructor(pattern, token = DEFAULT_TOKEN) { 36 | this.#token = token; 37 | this.#pattern = pattern; 38 | this.#patternCharList = this.#getPatternList(pattern, token); 39 | } 40 | 41 | /** 42 | * Returns value is masked or not 43 | * @param {string} value 44 | * @returns {boolean} - isMasked 45 | */ 46 | 47 | calcIsMasked(value) { 48 | if (!value) return false; 49 | 50 | const patternCharList = this.#patternCharList; 51 | 52 | const upperBound = Math.min(patternCharList.length, value.length); 53 | const subPatternList = patternCharList.slice(0, upperBound); 54 | const subValue = value.substring(0, upperBound); 55 | const regex = this.#toRegex(subPatternList, this.#token); 56 | 57 | return regex.test(subValue); 58 | } 59 | 60 | /** 61 | * Return regex that can be used to check value is masked or not 62 | * @param {Array.} patternCharList 63 | * @returns {RegExp} 64 | */ 65 | 66 | #toRegex(patternCharList) { 67 | const regexPattern = patternCharList 68 | .map((char) => (char === this.#token ? REGEX_TOKEN : char)) 69 | .join(''); 70 | const regex = new RegExp( 71 | REGEX_STARTING_CHAR + regexPattern + REGEX_ENDING_CHAR 72 | ); 73 | return regex; 74 | } 75 | 76 | /** 77 | * Returns augment that will be added to value according to pattern 78 | * @param {Object} payload 79 | * @param {string} payload.patternChar 80 | * @param {Array.} payload.valueCharList 81 | * @param {boolean} payload.isMasked 82 | * @returns {string} - augment 83 | */ 84 | 85 | #getAugmentByPattern({ patternChar, valueCharList, isMasked }) { 86 | if (patternChar === this.#token) { 87 | const valueChar = valueCharList.shift(); 88 | return valueChar || ''; 89 | } 90 | 91 | return isMasked ? patternChar : ''; 92 | } 93 | 94 | /** 95 | * Returns masked or unMasked value 96 | * @param {string} value 97 | * @param {boolean} isMasked 98 | * @returns {string} - masked to unMasked value 99 | */ 100 | 101 | #getSanitizedValue(value, isMasked) { 102 | let sanitizedValue = ''; 103 | const valueCharList = String(value) 104 | .split('') 105 | .filter((char) => !this.#patternCharList.includes(char)); 106 | 107 | while (this.#patternCharList.length && valueCharList.length) { 108 | const patternChar = this.#patternCharList.shift(); 109 | sanitizedValue += this.#getAugmentByPattern({ 110 | patternChar, 111 | valueCharList, 112 | isMasked, 113 | }); 114 | } 115 | 116 | sanitizedValue += valueCharList.join(''); 117 | 118 | this.#patternCharList = this.#getPatternList(this.#pattern, this.#token); 119 | 120 | return sanitizedValue; 121 | } 122 | 123 | /** 124 | * Returns masked value 125 | * @param {string} value 126 | * @returns {string} - masked value 127 | */ 128 | 129 | toMasked(value) { 130 | if (!value) return value; 131 | 132 | const maskedValue = this.#getSanitizedValue(value, true); 133 | 134 | return maskedValue; 135 | } 136 | 137 | /** 138 | * Returns unMasked value 139 | * @param {string} value 140 | * @returns {string} - unMasked value 141 | */ 142 | 143 | toUnMasked(value) { 144 | if (!value) return value; 145 | 146 | const unMaskedValue = this.#getSanitizedValue(value, false); 147 | 148 | return unMaskedValue; 149 | } 150 | } 151 | 152 | export default Mask; 153 | -------------------------------------------------------------------------------- /src/components/common/dialog/basic.component.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 89 | 90 | 159 | -------------------------------------------------------------------------------- /src/components/common/button/icon.component.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 57 | 58 | 159 | -------------------------------------------------------------------------------- /src/components/common/button/segmented.component.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 98 | 99 | 172 | -------------------------------------------------------------------------------- /src/components/common/scrim/item.component.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 156 | 157 | 197 | -------------------------------------------------------------------------------- /src/components/common/progress-indicator/circular.component.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 91 | 92 | 186 | -------------------------------------------------------------------------------- /src/components/common/input/stepper.component.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 142 | 143 | 196 | -------------------------------------------------------------------------------- /src/components/common/chip/filter.component.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 79 | 80 | 232 | -------------------------------------------------------------------------------- /src/components/common/input/switch.component.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 66 | 67 | 233 | -------------------------------------------------------------------------------- /src/components/common/field/filled-text.component.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 103 | 104 | 239 | -------------------------------------------------------------------------------- /src/components/common/button/base.component.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 90 | 91 | 243 | -------------------------------------------------------------------------------- /src/components/common/picker/date/base-calendar.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 136 | 137 | 237 | -------------------------------------------------------------------------------- /src/composable/use-draggable.composable.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {Object} Position 3 | * @property {number} x - Horizontal axis position 4 | * @property {number} y - Vertical axis position 5 | */ 6 | 7 | /** 8 | * useDraggable composable options 9 | * @typedef {Object} UseDraggableOptions 10 | * @property {string} [axis=both] - Drag direction 11 | * @property {HTMLElement} [containerElement=document.body] - Container element (draggable element boundaries) 12 | * @property {Position} [initialPosition={x: 0, y:0}] - Initial position of element 13 | * @property {boolean} [exact=false] - Only start dragging if exactly point to element 14 | * @property {boolean} [disabled=false] - Disable the dragging 15 | * @property {Function} dragStart - Call on drag start 16 | * @property {Function} dragMove - Call on drag move 17 | * @property {Function} dragEnd - Call on drag end 18 | * @property {boolean} [shouldUpdatePosition=true] - Update top and left css property of the target element 19 | */ 20 | 21 | import { onMounted, ref, toValue } from 'vue'; 22 | import { clamp } from '@/utils/math.util'; 23 | 24 | const axisValues = Object.freeze({ 25 | HORIZONTAL: 'horizontal', 26 | VERTICAL: 'vertical', 27 | BOTH: 'both', 28 | }); 29 | 30 | const DEFAULT_OPTIONS = Object.freeze({ 31 | axis: 'both', 32 | disabled: false, 33 | exact: false, 34 | containerElement: document.body, 35 | initialPosition: { x: 0, y: 0 }, 36 | shouldUpdatePosition: true, 37 | }); 38 | 39 | /** 40 | * Validate useDraggable composable options and throw error 41 | * if receive any invalid option 42 | * @param {UseDraggableOptions} options 43 | * @returns {undefined} 44 | */ 45 | const validateOptions = (options) => { 46 | const axis = toValue(options.axis); 47 | const validAxises = Object.values(axisValues); 48 | 49 | if (!validAxises.includes(axis)) { 50 | throw new Error(`Axis must be one of ${validAxises}`); 51 | } 52 | }; 53 | 54 | /** 55 | * useDraggable composable 56 | * @param {HTMLElement} element - Element to drag 57 | * @param {UsePromiseConfig} config - usePromise options 58 | * @returns {Object} - {position} 59 | */ 60 | const useDraggable = (element, preferredOptions = {}) => { 61 | const options = { ...DEFAULT_OPTIONS, ...preferredOptions }; 62 | const { 63 | axis, 64 | containerElement, 65 | initialPosition, 66 | exact, 67 | shouldUpdatePosition, 68 | } = options; 69 | 70 | validateOptions(options); 71 | 72 | /** 73 | * Set pointerMove and pointerUp events 74 | * @returns {undefined} 75 | */ 76 | const setListeners = () => { 77 | document.addEventListener('pointermove', dragMove); 78 | document.addEventListener('pointerup', dragEnd); 79 | }; 80 | 81 | /** 82 | * Remove pointerMove and pointerUp events 83 | * @returns {undefined} 84 | */ 85 | const removeListeners = () => { 86 | document.removeEventListener('pointermove', dragMove); 87 | document.removeEventListener('pointerup', dragEnd); 88 | }; 89 | 90 | let pointerStartPosition = { x: 0, y: 0 }; 91 | let elementOffset = { x: 0, y: 0 }; 92 | const position = ref(toValue(initialPosition)); 93 | const distancePercentage = ref({ x: 0, y: 0 }); 94 | 95 | /** 96 | * Initial the dragging requirements 97 | * @param {PointerEvent} event 98 | * @returns {undefined} 99 | */ 100 | const dragStart = (event) => { 101 | const disabled = toValue(options.disabled); 102 | if (event.button !== 0 || disabled) { 103 | return; 104 | } 105 | 106 | const target = toValue(element); 107 | if (toValue(exact) && event.target !== target) { 108 | return; 109 | } 110 | 111 | pointerStartPosition = { x: event.clientX, y: event.clientY }; 112 | elementOffset = { x: target.offsetLeft, y: target.offsetTop }; 113 | 114 | setListeners(); 115 | 116 | options.dragStart?.(position.value, event); 117 | }; 118 | 119 | /** 120 | * Calculate clamped value horizontally 121 | * @param {PointerEvent} event 122 | * @param {Object} payload 123 | * @param {HTMLElement} payload.target 124 | * @param {HTMLElement} payload.container 125 | * @returns {number} - clamped horizontal value 126 | */ 127 | const getClampedX = (event, { target, container }) => { 128 | const pointerCurrentXPosition = event.clientX; 129 | 130 | const pointerMovedDistance = 131 | elementOffset.x + pointerCurrentXPosition - pointerStartPosition.x; 132 | 133 | const containerWidth = container.clientWidth; 134 | const targetWidth = target.offsetWidth; 135 | const maxPosition = containerWidth - targetWidth; 136 | 137 | const clampedX = clamp(pointerMovedDistance, { max: maxPosition, min: 0 }); 138 | 139 | distancePercentage.value.x = Math.round((clampedX * 100) / maxPosition); 140 | 141 | return clampedX; 142 | }; 143 | 144 | /** 145 | * Calculate clamped value vertically 146 | * @param {PointerEvent} event 147 | * @param {Object} payload 148 | * @param {HTMLElement} payload.target 149 | * @param {HTMLElement} payload.container 150 | * @returns {number} - clamped vertical value 151 | */ 152 | const getClampedY = (event, { target, container }) => { 153 | const pointerCurrentYPosition = event.clientY; 154 | 155 | const pointerMovedDistance = 156 | elementOffset.y + pointerCurrentYPosition - pointerStartPosition.y; 157 | 158 | const containerHeight = container.clientHeight; 159 | const targetHeight = target.offsetHeight; 160 | const maxPosition = containerHeight - targetHeight; 161 | 162 | const clampedY = clamp(pointerMovedDistance, { max: maxPosition, min: 0 }); 163 | 164 | distancePercentage.value.y = Math.round((clampedY * 100) / maxPosition); 165 | 166 | return clampedY; 167 | }; 168 | 169 | /** 170 | * Handle pointer movement 171 | * @param {PointerEvent} event 172 | * @returns {undefined} 173 | */ 174 | const dragMove = (event) => { 175 | const target = toValue(element); 176 | const container = toValue(containerElement); 177 | const axisValue = toValue(axis); 178 | const shouldUpdate = toValue(shouldUpdatePosition); 179 | 180 | if (axisValue === 'both' || axisValue === 'horizontal') { 181 | const x = getClampedX(event, { target, container }); 182 | position.value.x = x; 183 | 184 | if (shouldUpdate) { 185 | target.style.left = `${x}px`; 186 | } 187 | } 188 | 189 | if (axisValue === 'both' || axisValue === 'vertical') { 190 | const y = getClampedY(event, { target, container }); 191 | position.value.y = y; 192 | 193 | if (shouldUpdate) { 194 | target.style.top = `${y}px`; 195 | } 196 | } 197 | 198 | options.dragMove?.(position.value, event); 199 | }; 200 | 201 | /** 202 | * Handle pointer movement end 203 | * @param {PointerEvent} event 204 | * @returns {undefined} 205 | */ 206 | const dragEnd = (event) => { 207 | removeListeners(); 208 | options.dragEnd?.(position.value, event); 209 | }; 210 | 211 | onMounted(() => { 212 | const target = toValue(element); 213 | target.addEventListener('pointerdown', dragStart); 214 | }); 215 | 216 | return { position, distancePercentage }; 217 | }; 218 | 219 | export default useDraggable; 220 | --------------------------------------------------------------------------------