├── devs ├── vue-meeting-selector-dev │ ├── env.d.ts │ ├── src │ │ ├── main.ts │ │ ├── App.vue │ │ └── components │ │ │ ├── SimpleExample.vue │ │ │ ├── SimpleAsyncExample.vue │ │ │ ├── MultiExample.vue │ │ │ └── SlotsExample.vue │ ├── public │ │ └── favicon.ico │ ├── tsconfig.json │ ├── .vscode │ │ ├── extensions.json │ │ └── settings.json │ ├── .editorconfig │ ├── index.html │ ├── tsconfig.app.json │ ├── vite.config.ts │ ├── tsconfig.node.json │ ├── eslint.config.ts │ ├── README.md │ └── package.json └── react-meeting-selector-dev │ ├── src │ ├── vite-env.d.ts │ ├── main.tsx │ ├── App.tsx │ └── components │ │ ├── SimpleExample.tsx │ │ ├── SimpleAsyncExample.tsx │ │ ├── MultiExample.tsx │ │ └── MultipleRendersElementsExample.tsx │ ├── tsconfig.json │ ├── vite.config.ts │ ├── index.html │ ├── eslint.config.ts │ ├── tsconfig.node.json │ ├── tsconfig.app.json │ ├── package.json │ ├── public │ └── vite.svg │ └── README.md ├── packages ├── vue-meeting-selector │ ├── env.d.ts │ ├── setupTests.ts │ ├── src │ │ ├── components │ │ │ └── meetingSelector │ │ │ │ ├── ArrowIcon.vue │ │ │ │ ├── Arrowicon.spec.ts │ │ │ │ ├── DayDisplay.vue │ │ │ │ ├── DayDisplay.spec.ts │ │ │ │ ├── MeetingsDisplay.vue │ │ │ │ ├── MeetingSlotDisplay.vue │ │ │ │ ├── MeetingSlotDisplay.spec.ts │ │ │ │ ├── MeetingsDisplay.spec.ts │ │ │ │ └── MeetingSelector.vue │ │ └── main.ts │ ├── tsconfig.vitest.json │ ├── tsconfig.json │ ├── vitest.config.ts │ ├── vite.config.ts │ ├── eslint.config.ts │ ├── package.json │ └── README.md ├── react-meeting-selector │ ├── setupTests.ts │ ├── src │ │ ├── vite-env.d.ts │ │ ├── main.ts │ │ └── components │ │ │ └── meetingSelector │ │ │ ├── ArrowIcon.tsx │ │ │ ├── ArrowIcon.spec.tsx │ │ │ ├── DayDisplay.spec.tsx │ │ │ ├── DayDisplay.tsx │ │ │ ├── MeetingDisplay.tsx │ │ │ ├── MeetingSlotDisplay.tsx │ │ │ ├── MeetingSlotDisplay.spec.tsx │ │ │ ├── MeetingDisplay.spec.tsx │ │ │ └── MeetingSelector.tsx │ ├── vite.config.d.ts │ ├── tsconfig.json │ ├── eslint.config.ts │ ├── vitest.config.ts │ ├── vite.config.ts │ ├── package.json │ └── README.md └── common │ ├── src │ ├── assets │ │ ├── fonts │ │ │ ├── icons-font.eot │ │ │ ├── icons-font.ttf │ │ │ ├── icons-font.woff │ │ │ └── icons-font.svg │ │ └── style │ │ │ ├── icons-font.css │ │ │ └── meeting-selector.scss │ ├── index.ts │ ├── types │ │ ├── meetingsByDayGenerator.type.ts │ │ └── meetingSelector.type.ts │ ├── constants │ │ ├── options.ts │ │ └── options.spec.ts │ └── helpers │ │ ├── meetingsByDayGenerator.ts │ │ └── meetingsByDayGenerator.spec.ts │ ├── tsconfig.json │ ├── vitest.config.ts │ └── package.json ├── docs ├── react-meeting-selector │ ├── whats-new.md │ ├── simple-example.md │ ├── simple-async-example.md │ ├── multiple-renders-elements-example.md │ ├── installation.md │ ├── multi-example.md │ └── props.md ├── public │ └── images │ │ ├── meeting.png │ │ └── doctolib.png ├── .vitepress │ ├── components │ │ ├── vue │ │ │ ├── MultiExample.vue │ │ │ ├── SimpleExample.vue │ │ │ ├── SlotsExample.vue │ │ │ └── SimpleAsyncExample.vue │ │ ├── react │ │ │ ├── MultiExample.tsx │ │ │ ├── SimpleExample.tsx │ │ │ ├── SimpleAsyncExample.tsx │ │ │ └── MultipleRendersElementsExample.tsx │ │ └── ReactAdaptater.vue │ └── config.mts ├── vue-meeting-selector │ ├── multi-example.md │ ├── simple-example.md │ ├── simple-async-example.md │ ├── whats-new.md │ ├── slots-example.md │ ├── installation.md │ ├── events.md │ ├── slots.md │ └── props.md ├── introduction.md ├── package.json ├── index.md └── common-meeting-selector │ ├── generate-placeholder.md │ └── generate-meetings-by-days.md ├── .prettierrc.json ├── .vscode └── extensions.json ├── .gitignore ├── pnpm-workspace.yaml ├── .github └── workflows │ ├── lint-and-test.yaml │ ├── publish-vue.yaml │ ├── publish-react.yaml │ └── deploy.yaml ├── package.json └── README.md /devs/vue-meeting-selector-dev/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/vue-meeting-selector/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /devs/react-meeting-selector-dev/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/react-meeting-selector/setupTests.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/vitest'; 2 | -------------------------------------------------------------------------------- /packages/react-meeting-selector/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/vue-meeting-selector/setupTests.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/vitest'; 2 | -------------------------------------------------------------------------------- /docs/react-meeting-selector/whats-new.md: -------------------------------------------------------------------------------- 1 | # What's New 2 | 3 | ## 1.0.0 4 | 5 | - First release 🎉 6 | -------------------------------------------------------------------------------- /docs/public/images/meeting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iNeoO/meeting-selector/HEAD/docs/public/images/meeting.png -------------------------------------------------------------------------------- /docs/.vitepress/components/vue/MultiExample.vue: -------------------------------------------------------------------------------- 1 | ../../../../devs/vue-meeting-selector-dev/src/components/MultiExample.vue -------------------------------------------------------------------------------- /docs/.vitepress/components/vue/SimpleExample.vue: -------------------------------------------------------------------------------- 1 | ../../../../devs/vue-meeting-selector-dev/src/components/SimpleExample.vue -------------------------------------------------------------------------------- /docs/.vitepress/components/vue/SlotsExample.vue: -------------------------------------------------------------------------------- 1 | ../../../../devs/vue-meeting-selector-dev/src/components/SlotsExample.vue -------------------------------------------------------------------------------- /docs/public/images/doctolib.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iNeoO/meeting-selector/HEAD/docs/public/images/doctolib.png -------------------------------------------------------------------------------- /docs/.vitepress/components/react/MultiExample.tsx: -------------------------------------------------------------------------------- 1 | ../../../../devs/react-meeting-selector-dev/src/components/MultiExample.tsx -------------------------------------------------------------------------------- /docs/.vitepress/components/react/SimpleExample.tsx: -------------------------------------------------------------------------------- 1 | ../../../../devs/react-meeting-selector-dev/src/components/SimpleExample.tsx -------------------------------------------------------------------------------- /docs/.vitepress/components/vue/SimpleAsyncExample.vue: -------------------------------------------------------------------------------- 1 | ../../../../devs/vue-meeting-selector-dev/src/components/SimpleAsyncExample.vue -------------------------------------------------------------------------------- /packages/react-meeting-selector/vite.config.d.ts: -------------------------------------------------------------------------------- 1 | declare const _default: import('vite').UserConfig; 2 | export default _default; 3 | -------------------------------------------------------------------------------- /docs/.vitepress/components/react/SimpleAsyncExample.tsx: -------------------------------------------------------------------------------- 1 | ../../../../devs/react-meeting-selector-dev/src/components/SimpleAsyncExample.tsx -------------------------------------------------------------------------------- /devs/vue-meeting-selector-dev/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue'; 2 | import App from './App.vue'; 3 | 4 | createApp(App).mount('#app'); 5 | -------------------------------------------------------------------------------- /packages/common/src/assets/fonts/icons-font.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iNeoO/meeting-selector/HEAD/packages/common/src/assets/fonts/icons-font.eot -------------------------------------------------------------------------------- /packages/common/src/assets/fonts/icons-font.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iNeoO/meeting-selector/HEAD/packages/common/src/assets/fonts/icons-font.ttf -------------------------------------------------------------------------------- /devs/vue-meeting-selector-dev/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iNeoO/meeting-selector/HEAD/devs/vue-meeting-selector-dev/public/favicon.ico -------------------------------------------------------------------------------- /packages/common/src/assets/fonts/icons-font.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iNeoO/meeting-selector/HEAD/packages/common/src/assets/fonts/icons-font.woff -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/prettierrc", 3 | "semi": true, 4 | "singleQuote": true, 5 | "printWidth": 100 6 | } 7 | -------------------------------------------------------------------------------- /docs/.vitepress/components/react/MultipleRendersElementsExample.tsx: -------------------------------------------------------------------------------- 1 | ../../../../devs/react-meeting-selector-dev/src/components/MultipleRendersElementsExample.tsx -------------------------------------------------------------------------------- /devs/react-meeting-selector-dev/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /packages/common/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './helpers/meetingsByDayGenerator'; 2 | export * from './types/meetingsByDayGenerator.type'; 3 | export * from './types/meetingSelector.type'; 4 | export * from './constants/options'; 5 | -------------------------------------------------------------------------------- /devs/react-meeting-selector-dev/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vite.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /devs/vue-meeting-selector-dev/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.node.json" 6 | }, 7 | { 8 | "path": "./tsconfig.app.json" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "Vue.volar", 4 | "vitest.explorer", 5 | "dbaeumer.vscode-eslint", 6 | "EditorConfig.EditorConfig", 7 | "oxc.oxc-vscode", 8 | "esbenp.prettier-vscode" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /devs/vue-meeting-selector-dev/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "Vue.volar", 4 | "dbaeumer.vscode-eslint", 5 | "EditorConfig.EditorConfig", 6 | "oxc.oxc-vscode", 7 | "esbenp.prettier-vscode" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /devs/react-meeting-selector-dev/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import App from './App'; 4 | 5 | createRoot(document.getElementById('root')!).render( 6 | 7 | 8 | 9 | ); 10 | -------------------------------------------------------------------------------- /devs/vue-meeting-selector-dev/.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}] 2 | charset = utf-8 3 | indent_size = 2 4 | indent_style = space 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | 8 | end_of_line = lf 9 | max_line_length = 100 10 | -------------------------------------------------------------------------------- /packages/common/src/types/meetingsByDayGenerator.type.ts: -------------------------------------------------------------------------------- 1 | export type Time = { 2 | hours: number; 3 | minutes: number; 4 | }; 5 | 6 | export type MeetingSlotGenerated = { 7 | date: Date; 8 | }; 9 | export type MeetingsByDayGenerated = { 10 | date: Date; 11 | slots: MeetingSlotGenerated[]; 12 | }; 13 | -------------------------------------------------------------------------------- /docs/vue-meeting-selector/multi-example.md: -------------------------------------------------------------------------------- 1 | # Multi example 2 | 3 | 4 | 5 | 6 | 7 | ::: code-group 8 | 9 | <<< ../.vitepress/components/vue/MultiExample.vue 10 | 11 | ::: 12 | 13 | 14 | -------------------------------------------------------------------------------- /docs/vue-meeting-selector/simple-example.md: -------------------------------------------------------------------------------- 1 | # Simple example 2 | 3 | 4 | 5 | 6 | 7 | ::: code-group 8 | 9 | <<< ../.vitepress/components/vue/SimpleExample.vue 10 | 11 | ::: 12 | 13 | 14 | -------------------------------------------------------------------------------- /packages/common/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "rootDir": "src", 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleResolution": "Node", 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true 11 | }, 12 | "include": ["src"] 13 | } 14 | -------------------------------------------------------------------------------- /devs/vue-meeting-selector-dev/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Vite App 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /docs/vue-meeting-selector/simple-async-example.md: -------------------------------------------------------------------------------- 1 | # Simple Async example 2 | 3 | 4 | 5 | 6 | 7 | ::: code-group 8 | 9 | <<< ../.vitepress/components/vue/SimpleAsyncExample.vue 10 | 11 | ::: 12 | 13 | 14 | -------------------------------------------------------------------------------- /devs/vue-meeting-selector-dev/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.dom.json", 3 | "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], 4 | "exclude": ["src/**/__tests__/*"], 5 | "compilerOptions": { 6 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 7 | 8 | "paths": { 9 | "@/*": ["./src/*"] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/common/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | coverage: { 6 | provider: 'v8', 7 | all: true, 8 | include: ['src/**/*'], 9 | exclude: ['src/index.ts'], 10 | thresholds: { 11 | lines: 100, 12 | functions: 100, 13 | branches: 100, 14 | statements: 100, 15 | }, 16 | }, 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /devs/react-meeting-selector-dev/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /packages/vue-meeting-selector/src/components/meetingSelector/ArrowIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 14 | -------------------------------------------------------------------------------- /docs/react-meeting-selector/simple-example.md: -------------------------------------------------------------------------------- 1 | # Simple example 2 | 3 | 4 | 5 | 6 | 7 | ::: code-group 8 | 9 | <<< ../.vitepress/components/react/SimpleExample.tsx 10 | 11 | ::: 12 | 13 | 17 | -------------------------------------------------------------------------------- /devs/vue-meeting-selector-dev/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url'; 2 | 3 | import { defineConfig } from 'vite'; 4 | import vue from '@vitejs/plugin-vue'; 5 | import vueDevTools from 'vite-plugin-vue-devtools'; 6 | 7 | // https://vite.dev/config/ 8 | export default defineConfig({ 9 | plugins: [vue(), vueDevTools()], 10 | resolve: { 11 | alias: { 12 | '@': fileURLToPath(new URL('./src', import.meta.url)), 13 | }, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /packages/vue-meeting-selector/tsconfig.vitest.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | 6 | "types": ["vitest/globals", "@testing-library/jest-dom", "vite/client", "node"] 7 | }, 8 | "include": [ 9 | "src/**/*.spec.ts", 10 | "src/**/*.spec.tsx", 11 | "src/**/*.test.ts", 12 | "src/**/*.test.tsx", 13 | "src/**/*.vue", 14 | "setupTests.ts" 15 | ], 16 | "exclude": ["node_modules", "dist"] 17 | } 18 | -------------------------------------------------------------------------------- /packages/react-meeting-selector/src/main.ts: -------------------------------------------------------------------------------- 1 | export { 2 | // meetingSelector.type.ts 3 | type MeetingSlot, 4 | type MeetingsByDay, 5 | type CalendarOptions, 6 | // slotsGenerator.type.ts 7 | type Time, 8 | type MeetingSlotGenerated, 9 | type MeetingsByDayGenerated, 10 | // slotsGenerator.ts 11 | generatePlaceHolder, 12 | generateMeetingsByDays, 13 | } from 'common-meeting-selector'; 14 | 15 | export { MeetingSelector } from './components/meetingSelector/MeetingSelector'; 16 | -------------------------------------------------------------------------------- /docs/react-meeting-selector/simple-async-example.md: -------------------------------------------------------------------------------- 1 | # Simple Async example 2 | 3 | 4 | 5 | 6 | 7 | ::: code-group 8 | 9 | <<< ../.vitepress/components/react/SimpleAsyncExample.tsx 10 | 11 | ::: 12 | 13 | 17 | -------------------------------------------------------------------------------- /packages/vue-meeting-selector/src/main.ts: -------------------------------------------------------------------------------- 1 | export { 2 | // meetingSelector.type.ts 3 | type MeetingSlot, 4 | type MeetingsByDay, 5 | type CalendarOptions, 6 | // slotsGenerator.type.ts 7 | type Time, 8 | type MeetingSlotGenerated, 9 | type MeetingsByDayGenerated, 10 | // slotsGenerator.ts 11 | generatePlaceHolder, 12 | generateMeetingsByDays, 13 | } from 'common-meeting-selector'; 14 | 15 | export { default as MeetingSelector } from './components/meetingSelector/MeetingSelector.vue'; 16 | -------------------------------------------------------------------------------- /.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 | doc/node_modules 12 | 13 | .DS_Store 14 | dist 15 | dist-ssr 16 | coverage 17 | *.local 18 | 19 | /cypress/videos/ 20 | /cypress/screenshots/ 21 | 22 | # Editor directories and files 23 | .vscode/* 24 | !.vscode/extensions.json 25 | .idea 26 | *.suo 27 | *.ntvs* 28 | *.njsproj 29 | *.sln 30 | *.sw? 31 | 32 | *.tsbuildinfo 33 | 34 | docs/.vitepress/cache/ -------------------------------------------------------------------------------- /devs/vue-meeting-selector-dev/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node22/tsconfig.json", 3 | "include": [ 4 | "vite.config.*", 5 | "vitest.config.*", 6 | "cypress.config.*", 7 | "nightwatch.conf.*", 8 | "playwright.config.*", 9 | "eslint.config.*" 10 | ], 11 | "compilerOptions": { 12 | "noEmit": true, 13 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 14 | 15 | "module": "ESNext", 16 | "moduleResolution": "Bundler", 17 | "types": ["node"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /docs/.vitepress/components/ReactAdaptater.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 22 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - packages/* 3 | - docs 4 | - devs/* 5 | catalog: 6 | vue: ^3.5.17 7 | react: ^19.1.0 8 | react-dom: ^19.1.0 9 | '@types/node': '^24.10.1' 10 | '@types/react': '^19.1.8' 11 | '@types/react-dom': '^19.1.6' 12 | vitest: ^4.0.14 13 | '@vitest/coverage-v8': ^4.0.14 14 | typescript: ^5.8.3 15 | eslint: ^9.39.1 16 | '@eslint/js': ^9.39.1 17 | chokidar-cli: ^3.0.0 18 | npm-run-all2: ^8.0.4 19 | vue-tsc: ^2.2.10 20 | prettier: ^3.5.3 21 | oxlint: ^1.1.0 22 | eslint-plugin-oxlint: ^1.1.0 23 | -------------------------------------------------------------------------------- /docs/react-meeting-selector/multiple-renders-elements-example.md: -------------------------------------------------------------------------------- 1 | # Multiple renders elements 2 | 3 | 4 | 5 | 6 | 7 | ::: code-group 8 | 9 | <<< ../.vitepress/components/react/MultipleRendersElementsExample.tsx 10 | 11 | ::: 12 | 13 | 17 | -------------------------------------------------------------------------------- /devs/vue-meeting-selector-dev/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "explorer.fileNesting.enabled": true, 3 | "explorer.fileNesting.patterns": { 4 | "tsconfig.json": "tsconfig.*.json, env.d.ts", 5 | "vite.config.*": "jsconfig*, vitest.config.*, cypress.config.*, playwright.config.*", 6 | "package.json": "package-lock.json, pnpm*, .yarnrc*, yarn*, .eslint*, eslint*, .oxlint*, oxlint*, .prettier*, prettier*, .editorconfig" 7 | }, 8 | "editor.codeActionsOnSave": { 9 | "source.fixAll": "explicit" 10 | }, 11 | "editor.formatOnSave": true, 12 | "editor.defaultFormatter": "esbenp.prettier-vscode" 13 | } 14 | -------------------------------------------------------------------------------- /packages/react-meeting-selector/src/components/meetingSelector/ArrowIcon.tsx: -------------------------------------------------------------------------------- 1 | import { type HTMLAttributes, memo } from 'react'; 2 | 3 | export type ArrowIconProps = { 4 | direction: 'up' | 'down' | 'left' | 'right'; 5 | } & HTMLAttributes; 6 | 7 | const ArrowIconComponent = ({ direction, className, ...props }: ArrowIconProps) => { 8 | const iconDirectionClass = `meeting-selector__icon-${direction}`; 9 | return ( 10 | 15 | ); 16 | }; 17 | 18 | export const ArrowIcon = memo(ArrowIconComponent); 19 | -------------------------------------------------------------------------------- /devs/vue-meeting-selector-dev/src/App.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 20 | -------------------------------------------------------------------------------- /docs/vue-meeting-selector/whats-new.md: -------------------------------------------------------------------------------- 1 | # What's New 2 | 3 | ## 4.0.0 4 | 5 | - Full rewrite of the component 6 | - Migration to a **pnpm monorepo** structure 7 | - Documentation now powered by **VitePress** 8 | - Improved typings with full **TypeScript generics** support 9 | - Cleaner API and simplified usage 10 | 11 | --- 12 | 13 | ## 3.1.0 14 | 15 | - Add spacing params: when clicking "next", scroll a custom number of slots 16 | 17 | ## 3.0.0 18 | 19 | - Upgrade to Vue 3 20 | - Switch to Vite for bundling (README updated accordingly) 21 | 22 | ## 1.1.0 23 | 24 | - Add support for selecting **multiple meetings** 25 | 26 | ## 1.0.0 27 | 28 | - First release 🎉 29 | -------------------------------------------------------------------------------- /docs/introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | **Meeting Selector** is a lightweight, fast, and fully customizable Vue component for selecting appointments. 4 | 5 | Inspired by the [Doctolib UI](https://www.doctolib.fr/medecin-generaliste/paris), it helps users pick a time slot from a list of available meetings grouped by day. The component includes built-in support for loading states, pagination, and more — making it ideal for scheduling workflows in modern web applications. 6 | 7 | It comes with **no CSS framework or design system dependency**, giving you full control over the styles and seamless integration with your existing UI. 8 | 9 | ![doctolib demo](/images/doctolib.png) 10 | -------------------------------------------------------------------------------- /packages/common/src/types/meetingSelector.type.ts: -------------------------------------------------------------------------------- 1 | export type MeetingSlot = { 2 | [K in DateFieldKey]: string | Date; 3 | } & Record; 4 | 5 | export type MeetingsByDay< 6 | DateFieldKey extends string, 7 | MeetingSlotsKey extends string, 8 | MSlot extends MeetingSlot 9 | > = { 10 | [K in DateFieldKey]: string | Date; 11 | } & { 12 | [K in MeetingSlotsKey]: MSlot[]; 13 | } & Record; 14 | 15 | export type CalendarOptions = { 16 | daysLabel?: string[]; 17 | monthsLabel?: string[]; 18 | limit?: number; 19 | spacing?: number; 20 | disabledDate?: (date: string | Date) => boolean; 21 | }; 22 | -------------------------------------------------------------------------------- /devs/react-meeting-selector-dev/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { SimpleAsyncExample } from './components/SimpleAsyncExample'; 2 | import { SimpleExample } from './components/SimpleExample'; 3 | import { MultiExample } from './components/MultiExample'; 4 | import { MultipleRendersElementsExample } from './components/MultipleRendersElementsExample'; 5 | 6 | const App = () => ( 7 | <> 8 |

Simple example

9 | 10 |

Simple async example

11 | 12 |

Multi example

13 | 14 |

Multiple renders elements example

15 | 16 | 17 | ); 18 | 19 | export default App; 20 | -------------------------------------------------------------------------------- /packages/react-meeting-selector/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "strict": true, 6 | "moduleResolution": "Bundler", 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "resolveJsonModule": true, 10 | "isolatedModules": true, 11 | "jsx": "react-jsx", 12 | "paths": { "@/*": ["./src/*"] }, 13 | "declaration": true, 14 | "declarationDir": "dist", 15 | "emitDeclarationOnly": true, 16 | "types": ["vite/client"] 17 | }, 18 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "env.d.ts"], 19 | "exclude": ["node_modules", "dist", "**/*.spec.*", "**/*.test.*", "setupTests.ts"] 20 | } 21 | -------------------------------------------------------------------------------- /packages/vue-meeting-selector/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "strict": true, 6 | "moduleResolution": "Bundler", 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "resolveJsonModule": true, 10 | "isolatedModules": true, 11 | "jsx": "preserve", 12 | "paths": { "@/*": ["./src/*"] }, 13 | "declaration": true, 14 | "declarationDir": "dist", 15 | "emitDeclarationOnly": true, 16 | 17 | "types": ["vite/client"] 18 | }, 19 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "env.d.ts"], 20 | "exclude": ["node_modules", "dist", "**/*.spec.*", "**/*.test.*", "setupTests.ts"] 21 | } 22 | -------------------------------------------------------------------------------- /packages/react-meeting-selector/src/components/meetingSelector/ArrowIcon.spec.tsx: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { render, screen } from '@testing-library/react'; 3 | import { ArrowIcon } from './ArrowIcon'; 4 | 5 | describe('ArrowIcon', () => { 6 | it.each(['up', 'down', 'left', 'right'] as const)( 7 | 'renders correct class for direction "%s"', 8 | (direction) => { 9 | render(); 10 | const icon = screen.getByRole('img'); 11 | expect(icon).toBeInTheDocument(); 12 | expect(icon).toHaveClass('meeting-selector__icon'); 13 | expect(icon).toHaveClass(`meeting-selector__icon-${direction}`); 14 | }, 15 | ); 16 | }); 17 | -------------------------------------------------------------------------------- /devs/react-meeting-selector-dev/eslint.config.ts: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | import { globalIgnores } from 'eslint/config' 7 | 8 | export default tseslint.config([ 9 | globalIgnores(['dist']), 10 | { 11 | files: ['**/*.{ts,tsx}'], 12 | extends: [ 13 | js.configs.recommended, 14 | tseslint.configs.recommended, 15 | reactHooks.configs['recommended-latest'], 16 | reactRefresh.configs.vite, 17 | ], 18 | languageOptions: { 19 | ecmaVersion: 2020, 20 | globals: globals.browser, 21 | }, 22 | }, 23 | ]) 24 | -------------------------------------------------------------------------------- /packages/react-meeting-selector/eslint.config.ts: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | import { globalIgnores } from 'eslint/config' 7 | 8 | export default tseslint.config([ 9 | globalIgnores(['dist']), 10 | { 11 | files: ['**/*.{ts,tsx}'], 12 | extends: [ 13 | js.configs.recommended, 14 | tseslint.configs.recommended, 15 | reactHooks.configs['recommended-latest'], 16 | reactRefresh.configs.vite, 17 | ], 18 | languageOptions: { 19 | ecmaVersion: 2020, 20 | globals: globals.browser, 21 | }, 22 | }, 23 | ]) 24 | -------------------------------------------------------------------------------- /packages/vue-meeting-selector/src/components/meetingSelector/Arrowicon.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { render, screen } from '@testing-library/vue'; 3 | import ArrowIcon from '../meetingSelector/ArrowIcon.vue'; 4 | 5 | describe('ArrowIcon', () => { 6 | it.each(['up', 'down', 'left', 'right'] as const)( 7 | 'renders correct class for direction "%s"', 8 | (direction) => { 9 | render(ArrowIcon, { 10 | props: { direction }, 11 | }); 12 | const icon = screen.getByRole('img'); 13 | expect(icon).toBeInTheDocument(); 14 | expect(icon).toHaveClass('meeting-selector__icon'); 15 | expect(icon).toHaveClass(`meeting-selector__icon-${direction}`); 16 | }, 17 | ); 18 | }); 19 | -------------------------------------------------------------------------------- /.github/workflows/lint-and-test.yaml: -------------------------------------------------------------------------------- 1 | name: Lint and Unit Tests 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | lint-and-build: 8 | name: Lint and Unit Tests 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Install pnpm 13 | uses: pnpm/action-setup@v4 14 | - name: Use Node.js 22 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: 22 18 | cache: 'pnpm' 19 | - name: Install dependencies 20 | run: pnpm install 21 | - name: Run linter 22 | run: pnpm lint 23 | - name: Run tests 24 | run: pnpm test 25 | - name: Calcul coverage 26 | run: pnpm coverage 27 | - name: Build 28 | run: pnpm build 29 | -------------------------------------------------------------------------------- /packages/react-meeting-selector/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react'; 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | export default defineConfig({ 5 | plugins: [react()], 6 | test: { 7 | globals: true, 8 | setupFiles: ['./setupTests.ts'], 9 | environment: 'jsdom', 10 | typecheck: { 11 | enabled: true, 12 | }, 13 | coverage: { 14 | provider: 'v8', 15 | all: true, 16 | include: ['src/**/*'], 17 | exclude: ['src/main.ts'], 18 | thresholds: { 19 | lines: 100, 20 | functions: 100, 21 | branches: 100, 22 | statements: 100, 23 | }, 24 | }, 25 | }, 26 | resolve: { 27 | alias: { 28 | '@': '/src', 29 | }, 30 | }, 31 | }); 32 | -------------------------------------------------------------------------------- /devs/react-meeting-selector-dev/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2023", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "verbatimModuleSyntax": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "erasableSyntaxOnly": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "noUncheckedSideEffectImports": true 23 | }, 24 | "include": ["vite.config.ts"] 25 | } 26 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "doc-meeting-selector", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "vitepress dev", 8 | "build": "vitepress build", 9 | "preview": "vitepress preview" 10 | }, 11 | "keywords": [], 12 | "author": "pique.valere@gmx.fr", 13 | "license": "MIT", 14 | "packageManager": "pnpm@10.13.1", 15 | "devDependencies": { 16 | "react": "^19.1.0", 17 | "react-dom": "^19.1.0", 18 | "vitepress": "^1.6.4" 19 | }, 20 | "dependencies": { 21 | "react": "catalog:", 22 | "react-meeting-selector": "workspace:^", 23 | "react-meeting-selector-dev": "workspace:^", 24 | "vue": "catalog:", 25 | "vue-component-type-helpers": "^3.0.3", 26 | "vue-meeting-selector": "workspace:^" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "common-meeting-selector", 3 | "version": "1.0.0", 4 | "description": "", 5 | "type": "module", 6 | "main": "src/index.ts", 7 | "exports": { 8 | ".": { 9 | "default": "./src/index.ts" 10 | }, 11 | "./style": { 12 | "default": "./src/assets/style/meeting-selector.scss" 13 | }, 14 | "./icons": { 15 | "default": "./src/assets/style/icons-font.css" 16 | } 17 | }, 18 | "scripts": { 19 | "test": "vitest", 20 | "coverage": "vitest run --coverage" 21 | }, 22 | "keywords": [], 23 | "author": "pique.valere@gmx.fr", 24 | "license": "MIT", 25 | "packageManager": "pnpm@10.13.1", 26 | "devDependencies": { 27 | "@vitest/coverage-v8": "catalog:", 28 | "typescript": "catalog:", 29 | "vitest": "catalog:" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /devs/react-meeting-selector-dev/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2022", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "verbatimModuleSyntax": true, 13 | "moduleDetection": "force", 14 | "jsx": "react-jsx", 15 | 16 | "declaration": true, 17 | "declarationDir": "dist", 18 | "emitDeclarationOnly": true, 19 | 20 | /* Linting */ 21 | "strict": true, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "erasableSyntaxOnly": true, 25 | "noFallthroughCasesInSwitch": true, 26 | "noUncheckedSideEffectImports": true 27 | }, 28 | "include": ["src"] 29 | } 30 | -------------------------------------------------------------------------------- /devs/react-meeting-selector-dev/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-meeting-selector-dev", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "react": "catalog:", 14 | "react-dom": "catalog:", 15 | "react-meeting-selector": "workspace:*" 16 | }, 17 | "devDependencies": { 18 | "@eslint/js": "catalog:", 19 | "@types/react": "catalog:", 20 | "@types/react-dom": "catalog:", 21 | "@vitejs/plugin-react": "^4.6.0", 22 | "eslint": "catalog:", 23 | "eslint-plugin-react-hooks": "^5.2.0", 24 | "eslint-plugin-react-refresh": "^0.4.20", 25 | "globals": "^16.3.0", 26 | "typescript": "catalog:", 27 | "typescript-eslint": "^8.35.1", 28 | "vite": "^7.2.6" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/vue-meeting-selector/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url'; 2 | import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'; 3 | import viteConfig from './vite.config'; 4 | 5 | export default mergeConfig( 6 | viteConfig, 7 | defineConfig({ 8 | test: { 9 | globals: true, 10 | setupFiles: ['./setupTests.ts'], 11 | environment: 'jsdom', 12 | typecheck: { 13 | enabled: true, 14 | }, 15 | coverage: { 16 | provider: 'v8', 17 | all: true, 18 | include: ['src/**/*'], 19 | exclude: ['src/main.ts'], 20 | thresholds: { 21 | lines: 100, 22 | functions: 100, 23 | branches: 100, 24 | statements: 100, 25 | }, 26 | }, 27 | exclude: [...configDefaults.exclude], 28 | root: fileURLToPath(new URL('./', import.meta.url)), 29 | }, 30 | }), 31 | ); 32 | -------------------------------------------------------------------------------- /.github/workflows/publish-vue.yaml: -------------------------------------------------------------------------------- 1 | name: Publish vue package 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | branch: 7 | description: 'Branch to pull' 8 | required: true 9 | default: 'master' 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | publish: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | with: 20 | ref: ${{ github.event.inputs.branch }} 21 | 22 | - uses: actions/setup-node@v4 23 | with: 24 | node-version: '22' 25 | registry-url: 'https://registry.npmjs.org' 26 | always-auth: true 27 | 28 | - uses: pnpm/action-setup@v4 29 | 30 | - run: pnpm install --frozen-lockfile 31 | - run: pnpm run build:vue-meeting-selector 32 | 33 | - name: Publish 34 | working-directory: packages/vue-meeting-selector 35 | env: 36 | NODE_AUTH_TOKEN: ${{ secrets.VUE_NPM_TOKEN }} 37 | run: npm publish --access public 38 | -------------------------------------------------------------------------------- /.github/workflows/publish-react.yaml: -------------------------------------------------------------------------------- 1 | name: Publish react package 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | branch: 7 | description: 'Branch to pull' 8 | required: true 9 | default: 'master' 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | publish: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | with: 20 | ref: ${{ github.event.inputs.branch }} 21 | 22 | - uses: actions/setup-node@v4 23 | with: 24 | node-version: '22' 25 | registry-url: 'https://registry.npmjs.org' 26 | always-auth: true 27 | 28 | - uses: pnpm/action-setup@v4 29 | 30 | - run: pnpm install --frozen-lockfile 31 | - run: pnpm run build:react-meeting-selector 32 | 33 | - name: Publish 34 | working-directory: packages/react-meeting-selector 35 | env: 36 | NODE_AUTH_TOKEN: ${{ secrets.REACT_NPM_TOKEN }} 37 | run: npm publish --access public 38 | -------------------------------------------------------------------------------- /packages/react-meeting-selector/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url'; 2 | 3 | import { defineConfig } from 'vite'; 4 | import react from '@vitejs/plugin-react'; 5 | import dts from 'vite-plugin-dts'; 6 | 7 | // https://vite.dev/config/ 8 | export default defineConfig({ 9 | plugins: [ 10 | react(), 11 | dts({ 12 | entryRoot: 'src', 13 | insertTypesEntry: true, 14 | }), 15 | ], 16 | build: { 17 | lib: { 18 | entry: fileURLToPath(new URL('./src/main.ts', import.meta.url)), 19 | name: 'react-meeting-selector', 20 | fileName: (format) => `react-meeting-selector.${format}.js`, 21 | }, 22 | cssCodeSplit: true, 23 | rollupOptions: { 24 | external: ['react', 'react-dom'], 25 | output: { 26 | globals: { 27 | react: 'React', 28 | 'react-dom': 'ReactDOM', 29 | }, 30 | }, 31 | }, 32 | }, 33 | resolve: { 34 | alias: { 35 | '@': fileURLToPath(new URL('./src', import.meta.url)), 36 | }, 37 | }, 38 | }); 39 | -------------------------------------------------------------------------------- /devs/vue-meeting-selector-dev/eslint.config.ts: -------------------------------------------------------------------------------- 1 | import { globalIgnores } from 'eslint/config'; 2 | import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'; 3 | import pluginVue from 'eslint-plugin-vue'; 4 | import pluginOxlint from 'eslint-plugin-oxlint'; 5 | import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'; 6 | 7 | // To allow more languages other than `ts` in `.vue` files, uncomment the following lines: 8 | // import { configureVueProject } from '@vue/eslint-config-typescript' 9 | // configureVueProject({ scriptLangs: ['ts', 'tsx'] }) 10 | // More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup 11 | 12 | export default defineConfigWithVueTs( 13 | { 14 | name: 'app/files-to-lint', 15 | files: ['**/*.{ts,mts,tsx,vue}'], 16 | }, 17 | 18 | globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']), 19 | 20 | pluginVue.configs['flat/essential'], 21 | vueTsConfigs.recommended, 22 | ...pluginOxlint.configs['flat/recommended'], 23 | skipFormatting, 24 | ); 25 | -------------------------------------------------------------------------------- /packages/vue-meeting-selector/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url'; 2 | 3 | import { defineConfig } from 'vite'; 4 | import vue from '@vitejs/plugin-vue'; 5 | import vueDevTools from 'vite-plugin-vue-devtools'; 6 | import dts from 'vite-plugin-dts'; 7 | 8 | // https://vite.dev/config/ 9 | export default defineConfig({ 10 | plugins: [ 11 | vue(), 12 | vueDevTools(), 13 | dts({ 14 | entryRoot: 'src', 15 | insertTypesEntry: true, 16 | }), 17 | ], 18 | build: { 19 | lib: { 20 | entry: fileURLToPath(new URL('./src/main.ts', import.meta.url)), 21 | name: 'vue-meeting-selector', 22 | fileName: (format) => `vue-meeting-selector.${format}.js`, 23 | }, 24 | cssCodeSplit: true, 25 | rollupOptions: { 26 | external: ['vue'], 27 | output: { 28 | globals: { 29 | vue: 'Vue', 30 | }, 31 | }, 32 | }, 33 | }, 34 | resolve: { 35 | alias: { 36 | '@': fileURLToPath(new URL('./src', import.meta.url)), 37 | }, 38 | }, 39 | }); 40 | -------------------------------------------------------------------------------- /devs/vue-meeting-selector-dev/README.md: -------------------------------------------------------------------------------- 1 | # vue-meeting-selector-dev 2 | 3 | This template should help get you started developing with Vue 3 in Vite. 4 | 5 | ## Recommended IDE Setup 6 | 7 | [VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur). 8 | 9 | ## Type Support for `.vue` Imports in TS 10 | 11 | TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types. 12 | 13 | ## Customize configuration 14 | 15 | See [Vite Configuration Reference](https://vite.dev/config/). 16 | 17 | ## Project Setup 18 | 19 | ```sh 20 | pnpm install 21 | ``` 22 | 23 | ### Compile and Hot-Reload for Development 24 | 25 | ```sh 26 | pnpm dev 27 | ``` 28 | 29 | ### Type-Check, Compile and Minify for Production 30 | 31 | ```sh 32 | pnpm build 33 | ``` 34 | 35 | ### Lint with [ESLint](https://eslint.org/) 36 | 37 | ```sh 38 | pnpm lint 39 | ``` 40 | -------------------------------------------------------------------------------- /packages/common/src/assets/style/icons-font.css: -------------------------------------------------------------------------------- 1 | /* Generated by Glyphter (http://www.glyphter.com) on Thu Jan 23 2020*/ 2 | @font-face { 3 | font-family: 'icons font'; 4 | src: url('../fonts/icons-font.eot'); 5 | src: url('../fonts/icons-font.eot?#iefix') format('embedded-opentype'), 6 | url('../fonts/icons-font.woff') format('woff'), 7 | url('../fonts/icons-font.ttf') format('truetype'), 8 | url('../fonts/icons-font.svg#icons-font') format('svg'); 9 | font-weight: normal; 10 | font-style: normal; 11 | } 12 | [class*='meeting-selector__icon-']:before { 13 | display: inline-block; 14 | font-family: 'icons font'; 15 | font-style: normal; 16 | font-weight: normal; 17 | line-height: 1; 18 | -webkit-font-smoothing: antialiased; 19 | -moz-osx-font-smoothing: grayscale; 20 | } 21 | .meeting-selector__icon-left:before { 22 | content: '\0041'; 23 | } 24 | .meeting-selector__icon-right:before { 25 | content: '\0042'; 26 | } 27 | .meeting-selector__icon-up:before { 28 | content: '\0043'; 29 | } 30 | .meeting-selector__icon-down:before { 31 | content: '\0044'; 32 | } 33 | .meeting-selector__icon-loader:before { 34 | content: '\0045'; 35 | } 36 | -------------------------------------------------------------------------------- /docs/vue-meeting-selector/slots-example.md: -------------------------------------------------------------------------------- 1 | # Slots example 2 | 3 | 4 | 5 | 6 | 7 | > 💡 **Accessing the component ref** 8 | > Since `` is a generic component, you need to install [`vue-component-type-helpers`](https://www.npmjs.com/package/vue-component-type-helpers) to access its ref with full type safety. 9 | > 10 | > Here's how you can do it: 11 | > 12 | > ::: code-group 13 | > 14 | > ```bash [npm] 15 | > npm install vue-component-type-helpers 16 | > ``` 17 | > 18 | > ```bash [pnpm] 19 | > pnpm add vue-component-type-helpers 20 | > ``` 21 | > 22 | > ```bash [yarn] 23 | > yarn add vue-component-type-helpers 24 | > ``` 25 | > 26 | > ::: 27 | > 28 | > ```ts 29 | > import { useTemplateRef } from 'vue-component-type-helpers'; 30 | > import type { MeetingSelectorType } from '@/types/MeetingSelectorType'; 31 | > 32 | > const meetingSelector = useTemplateRef('meetingSelector'); 33 | > ``` 34 | 35 | ::: code-group 36 | 37 | <<< ../.vitepress/components/vue/SlotsExample.vue 38 | 39 | ::: 40 | 41 | 42 | -------------------------------------------------------------------------------- /packages/common/src/constants/options.ts: -------------------------------------------------------------------------------- 1 | import type { CalendarOptions } from '../types/meetingSelector.type'; 2 | 3 | const days: string[] = [ 4 | 'sunday', 5 | 'monday', 6 | 'tuesday', 7 | 'wednesday', 8 | 'thursday', 9 | 'friday', 10 | 'saturday', 11 | ]; 12 | 13 | const months: string[] = [ 14 | 'jan.', 15 | 'feb.', 16 | 'mar.', 17 | 'apr.', 18 | 'may.', 19 | 'jun.', 20 | 'jul.', 21 | 'aug.', 22 | 'sep.', 23 | 'oct.', 24 | 'nov.', 25 | 'dec.', 26 | ]; 27 | 28 | const decomposeDate = (date: Date): number => { 29 | const month = date.getMonth() + 1 < 10 ? `0${date.getMonth() + 1}` : date.getMonth() + 1; 30 | const day = date.getDate() < 10 ? `0${date.getDate()}` : date.getDate(); 31 | return parseInt(`${date.getFullYear()}${month}${day}`, 10); 32 | }; 33 | 34 | const disabledDate = (date: Date | string): boolean => { 35 | const actualDate = new Date(date); 36 | const today = new Date(); 37 | return decomposeDate(actualDate) <= decomposeDate(today); 38 | }; 39 | 40 | export const defaultCalendarOptions: Required = { 41 | daysLabel: days, 42 | monthsLabel: months, 43 | limit: 4, 44 | spacing: 4, 45 | disabledDate, 46 | }; 47 | -------------------------------------------------------------------------------- /packages/vue-meeting-selector/eslint.config.ts: -------------------------------------------------------------------------------- 1 | import { globalIgnores } from 'eslint/config'; 2 | import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'; 3 | import pluginVue from 'eslint-plugin-vue'; 4 | import pluginVitest from '@vitest/eslint-plugin'; 5 | import pluginOxlint from 'eslint-plugin-oxlint'; 6 | import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'; 7 | 8 | // To allow more languages other than `ts` in `.vue` files, uncomment the following lines: 9 | // import { configureVueProject } from '@vue/eslint-config-typescript' 10 | // configureVueProject({ scriptLangs: ['ts', 'tsx'] }) 11 | // More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup 12 | 13 | export default defineConfigWithVueTs( 14 | { 15 | name: 'app/files-to-lint', 16 | files: ['**/*.{ts,mts,tsx,vue}'], 17 | }, 18 | 19 | globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']), 20 | 21 | pluginVue.configs['flat/essential'], 22 | vueTsConfigs.recommended, 23 | 24 | { 25 | ...pluginVitest.configs.recommended, 26 | files: ['src/**/__tests__/*'], 27 | }, 28 | ...pluginOxlint.configs['flat/recommended'], 29 | skipFormatting, 30 | ); 31 | -------------------------------------------------------------------------------- /packages/vue-meeting-selector/src/components/meetingSelector/DayDisplay.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 43 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yaml: -------------------------------------------------------------------------------- 1 | name: build docs 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | sanity: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Start SSH agent 11 | uses: webfactory/ssh-agent@v0.9.0 12 | with: 13 | ssh-private-key: | 14 | ${{ secrets.SSH_PRIVATE_KEY }} 15 | 16 | - name: Add host to known_hosts 17 | run: | 18 | ssh-keyscan -p "${{ secrets.SSH_PORT || '22' }}" "${{ secrets.SSH_HOST }}" >> ~/.ssh/known_hosts 19 | 20 | - name: Rebase on master and build docs (remote) 21 | env: 22 | SSH_HOST: ${{ secrets.SSH_HOST }} 23 | SSH_USER: ${{ secrets.SSH_USER }} 24 | SSH_PORT: ${{ secrets.SSH_PORT }} 25 | run: | 26 | set -euo pipefail 27 | ssh -o StrictHostKeyChecking=yes -p "${SSH_PORT:-22}" "$SSH_USER@$SSH_HOST" <<'EOF' 28 | set -e 29 | REPO_DIR="$HOME/github/ineoo/meeting-selector" 30 | test -d "$REPO_DIR/.git" || { echo "Repo not found at $REPO_DIR"; exit 2; } 31 | cd "$REPO_DIR" 32 | git pull --rebase origin master 33 | pnpm install --no-frozen-lockfile 34 | pnpm run build:react-meeting-selector 35 | pnpm run build:vue-meeting-selector 36 | pnpm run build:docs 37 | EOF 38 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | hero: 6 | name: 'meeting-selector' 7 | text: 'Documentation for meeting-selector' 8 | tagline: a meeting selector in multi frameworks 9 | actions: 10 | - theme: brand 11 | text: vue-meeting-selector 12 | link: /vue-meeting-selector/installation 13 | - theme: brand 14 | text: react-meeting-selector 15 | link: /react-meeting-selector/installation 16 | image: 17 | src: /images/meeting.png 18 | alt: meeting-selector 19 | 20 | features: 21 | - icon: 🎨 22 | title: Fully customizable rendering 23 | details: Render your own headers, time slots, and navigation buttons using slots (Vue) or render props (React). Integrate seamlessly with your design system. 24 | 25 | - icon: ✅ 26 | title: Supports single and multiple selections 27 | details: Out of the box support for both single-slot and multi-slot selection modes. Typed and fully controlled. 28 | 29 | - icon: ⚙️ 30 | title: Built-in pagination and loading 31 | details: Handle long lists of appointments with configurable pagination, loading states, and async-friendly hooks. 32 | 33 | - icon: 🌐 34 | title: Works with Vue & React 35 | details: Use the same core logic in Vue 3 or React, with consistent typing and feature parity across both frameworks. 36 | --- 37 | -------------------------------------------------------------------------------- /devs/vue-meeting-selector-dev/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-meeting-selector-dev", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "run-p type-check \"build-only {@}\" --", 9 | "preview": "vite preview", 10 | "build-only": "vite build", 11 | "type-check": "vue-tsc --build", 12 | "lint:oxlint": "oxlint . --fix -D correctness --ignore-path .gitignore", 13 | "lint:eslint": "eslint . --fix", 14 | "lint": "run-s lint:*", 15 | "format": "prettier --write src/" 16 | }, 17 | "dependencies": { 18 | "vue": "catalog:", 19 | "vue-component-type-helpers": "^3.0.3", 20 | "vue-meeting-selector": "workspace:^" 21 | }, 22 | "devDependencies": { 23 | "@tsconfig/node22": "^22.0.2", 24 | "@types/node": "^22.15.32", 25 | "@vitejs/plugin-vue": "^6.0.0", 26 | "@vue/eslint-config-prettier": "^10.2.0", 27 | "@vue/eslint-config-typescript": "^14.5.1", 28 | "@vue/tsconfig": "^0.7.0", 29 | "eslint": "catalog:", 30 | "eslint-plugin-oxlint": "~1.1.0", 31 | "eslint-plugin-vue": "~10.2.0", 32 | "jiti": "^2.4.2", 33 | "npm-run-all2": "catalog:", 34 | "oxlint": "catalog:", 35 | "prettier": "catalog:", 36 | "typescript": "catalog:", 37 | "vite": "7.2.6", 38 | "vite-plugin-vue-devtools": "^8.0.5", 39 | "vue-tsc": "catalog:" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /docs/vue-meeting-selector/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | ::: code-group 4 | 5 | ```bash [npm] 6 | npm install vue-meeting-selector 7 | ``` 8 | 9 | ```bash [pnpm] 10 | pnpm add vue-meeting-selector 11 | ``` 12 | 13 | ```bash [yarn] 14 | yarn add vue-meeting-selector 15 | ``` 16 | 17 | ::: 18 | 19 | ## Dependencies 20 | 21 | - required: Vuejs >= 3.5.x 22 | 23 | ## Usage 24 | 25 | In your component 26 | 27 | ```typescript 28 | import { MeetingSelector } from 'vue-meeting-selector'; 29 | import 'vue-meeting-selector/style.css'; 30 | ``` 31 | 32 | ## Helpers 33 | 34 | `react-meeting-selector` provides utilities to generate mock or structured data compatible with the component. 35 | 36 | ### `generateMeetingsByDays` 37 | 38 | Generates meeting slots at regular intervals within a time range, grouped by day. Useful for testing or real-world slot generation. 39 | 40 | ```typescript 41 | import { generateMeetingsByDays } from 'vue-meeting-selector'; 42 | ``` 43 | 44 | [Learn more about `generateMeetingsByDays`](/common-meeting-selector/generate-meetings-by-days.html) 45 | 46 | ### generatePlaceholder 47 | 48 | Returns a placeholder structure compatible with the component's expected shape, typically used to show loading skeletons or empty calendars. 49 | 50 | ```typescript 51 | import { generatePlaceholder } from 'vue-meeting-selector'; 52 | ``` 53 | 54 | [Learn more about `generatePlaceholder`](/common-meeting-selector/generate-placeholder.html) 55 | -------------------------------------------------------------------------------- /packages/react-meeting-selector/src/components/meetingSelector/DayDisplay.spec.tsx: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { render, screen } from '@testing-library/react'; 3 | import { DayDisplay } from './DayDisplay'; 4 | 5 | describe('DayDisplay', () => { 6 | it('renders the correct title and subtitle based on the meetingsByDay prop', () => { 7 | const meetingsByDay = { 8 | date: '2025-08-10T12:00:00Z', 9 | slots: [], 10 | }; 11 | 12 | const daysLabel = [ 13 | 'Sunday', 14 | 'Monday', 15 | 'Tuesday', 16 | 'Wednesday', 17 | 'Thursday', 18 | 'Friday', 19 | 'Saturday', 20 | ]; 21 | const monthsLabel = [ 22 | 'January', 23 | 'February', 24 | 'March', 25 | 'April', 26 | 'May', 27 | 'June', 28 | 'July', 29 | 'August', 30 | 'September', 31 | 'October', 32 | 'November', 33 | 'December', 34 | ]; 35 | 36 | render( 37 | , 44 | ); 45 | 46 | const titleElement = screen.getByText('Sunday'); 47 | expect(titleElement).toBeInTheDocument(); 48 | 49 | const subtitleElement = screen.getByText('10 August'); 50 | expect(subtitleElement).toBeInTheDocument(); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /docs/react-meeting-selector/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | ::: code-group 4 | 5 | ```bash [npm] 6 | npm install react-meeting-selector 7 | ``` 8 | 9 | ```bash [pnpm] 10 | pnpm add react-meeting-selector 11 | ``` 12 | 13 | ```bash [yarn] 14 | yarn add react-meeting-selector 15 | ``` 16 | 17 | ::: 18 | 19 | ## Dependencies 20 | 21 | - required: react >= 19.x 22 | 23 | ## Usage 24 | 25 | In your component 26 | 27 | ```typescript 28 | import { MeetingSelector } from 'react-meeting-selector'; 29 | import 'react-meeting-selector/style.css'; 30 | ``` 31 | 32 | ## Helpers 33 | 34 | `react-meeting-selector` provides utilities to generate mock or structured data compatible with the component. 35 | 36 | ### `generateMeetingsByDays` 37 | 38 | Generates meeting slots at regular intervals within a time range, grouped by day. Useful for testing or real-world slot generation. 39 | 40 | ```typescript 41 | import { generateMeetingsByDays } from 'react-meeting-selector'; 42 | ``` 43 | 44 | [Learn more about `generateMeetingsByDays`](/common-meeting-selector/generate-meetings-by-days.html) 45 | 46 | ### generatePlaceholder 47 | 48 | Returns a placeholder structure compatible with the component's expected shape, typically used to show loading skeletons or empty calendars. 49 | 50 | ```typescript 51 | import { generatePlaceholder } from 'react-meeting-selector'; 52 | ``` 53 | 54 | [Learn more about `generatePlaceholder`](/common-meeting-selector/generate-placeholder.html) 55 | -------------------------------------------------------------------------------- /docs/vue-meeting-selector/events.md: -------------------------------------------------------------------------------- 1 | # Events 2 | 3 | This component emits several events related to date navigation and meeting slot selection. 4 | 5 | ## Event List 6 | 7 | | Event Name | Payload Type | Description | 8 | | ------------------- | ----------------------------------------- | --------------------------------------------------------------- | 9 | | `next-date` | — | Emitted when the user moves to the next date view. | 10 | | `previous-date` | — | Emitted when the user moves to the previous date view. | 11 | | `update:skip` | `(skip: number)` | Emitted when the `skip` value changes (e.g. pagination). | 12 | | `update:modelValue` | `(meetingSlot: MSlot \| MSlot[] \| null)` | Emitted when the selected meeting slot(s) change via `v-model`. | 13 | | `change` | `(meetingSlot: MSlot \| MSlot[] \| null)` | Emitted on selection change (manual or programmatic). | 14 | 15 | ## Notes 16 | 17 | - `update:modelValue` is part of the v-model contract and should be used to sync the selection externally. 18 | 19 | - `change` is a convenience event for reacting to changes in selection, regardless of source. 20 | 21 | - `next-date` and `previous-date` are useful for implementing pagination or custom date navigation controls. 22 | -------------------------------------------------------------------------------- /devs/react-meeting-selector-dev/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/common/src/constants/options.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { defaultCalendarOptions } from './options'; 3 | 4 | describe('options', () => { 5 | describe('defaultCalendarOptions', () => { 6 | it('should have correct daysLabel', () => { 7 | expect(defaultCalendarOptions.daysLabel).toEqual([ 8 | 'sunday', 9 | 'monday', 10 | 'tuesday', 11 | 'wednesday', 12 | 'thursday', 13 | 'friday', 14 | 'saturday', 15 | ]); 16 | }); 17 | 18 | it('should have correct monthsLabel', () => { 19 | expect(defaultCalendarOptions.monthsLabel).toEqual([ 20 | 'jan.', 21 | 'feb.', 22 | 'mar.', 23 | 'apr.', 24 | 'may.', 25 | 'jun.', 26 | 'jul.', 27 | 'aug.', 28 | 'sep.', 29 | 'oct.', 30 | 'nov.', 31 | 'dec.', 32 | ]); 33 | }); 34 | 35 | it('should have correct limit', () => { 36 | expect(defaultCalendarOptions.limit).toBe(4); 37 | }); 38 | 39 | it('should have correct spacing', () => { 40 | expect(defaultCalendarOptions.spacing).toBe(4); 41 | }); 42 | 43 | it('should disable past dates', () => { 44 | const pastDate = new Date('2023-01-01'); 45 | expect(defaultCalendarOptions.disabledDate(pastDate)).toBe(true); 46 | }); 47 | 48 | it('should not disable future dates', () => { 49 | const futureDate = new Date('2030-10-10'); 50 | expect(defaultCalendarOptions.disabledDate(futureDate)).toBe(false); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "meeting-selector", 3 | "version": "1.0.1", 4 | "description": "Monorepo for meeting-selector components", 5 | "scripts": { 6 | "dev:vue-meeting-selector": "pnpm --filter vue-meeting-selector dev & pnpm --filter vue-meeting-selector-dev dev ", 7 | "dev:react-meeting-selector": "pnpm --filter react-meeting-selector dev & pnpm --filter react-meeting-selector-dev dev", 8 | "build:vue-meeting-selector": "pnpm --filter vue-meeting-selector build", 9 | "build:vue-meeting-selector-dev": "pnpm --filter vue-meeting-selector-dev build", 10 | "build:react-meeting-selector": "pnpm --filter react-meeting-selector build", 11 | "build:react-meeting-selector-dev": "pnpm --filter react-meeting-selector-dev build", 12 | "dev:docs": "pnpm --filter doc-meeting-selector dev", 13 | "build:docs": "pnpm --filter doc-meeting-selector build", 14 | "build": "pnpm run build:vue-meeting-selector && pnpm run build:react-meeting-selector && pnpm run build:docs && pnpm run build:vue-meeting-selector-dev && pnpm run build:react-meeting-selector-dev", 15 | "lint": "pnpm -r run lint", 16 | "test": "pnpm -r run test", 17 | "coverage": "pnpm -r run coverage" 18 | }, 19 | "keywords": [], 20 | "author": "pique.valere@gmx.fr", 21 | "license": "MIT", 22 | "packageManager": "pnpm@10.13.1", 23 | "pnpm": { 24 | "overrides": { 25 | "@docsearch/react": "^4.3.2", 26 | "@docsearch/js": "^4.3.2", 27 | "glob": "^10.5.0", 28 | "esbuild": "^0.25.0", 29 | "js-yaml": "^4.1.1", 30 | "mdast-util-to-hast": "^13.2.1" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /docs/react-meeting-selector/multi-example.md: -------------------------------------------------------------------------------- 1 | # Multi Async example 2 | 3 | 4 | 5 | 6 | 7 | > 💡 **Explicit generic types are required** 8 | > 9 | > Since `` is a generic component (with 4 generic parameters), you must **explicitly provide the generic arguments** (`DateFieldKey`, `MeetingSlotsKey`, `MSlot`, `MDay`) to avoid TypeScript inference issues — especially when using `multi={true}` or complex data shapes. 10 | > 11 | > Without these, TypeScript cannot infer the right slot or date keys, and you'll get type mismatches on `value`, `handleValueChange`, or `renderMeeting`. 12 | > 13 | > Here's how you should use it: 14 | > 15 | > ```tsx 16 | > 17 | > value={value} 18 | > date={date} 19 | > skip={skip} 20 | > multi={true} 21 | > loading={loading} 22 | > handleValueChange={handleChange} 23 | > meetingsByDays={meetingsDays} 24 | > dateFieldKey="date" 25 | > meetingSlotsKey="slots" 26 | > handleNextDate={nextDate} 27 | > handlePreviousDate={previousDate} 28 | > handleSkipChange={setSkip} 29 | > /> 30 | > ``` 31 | > 32 | > Make sure `MeetingSlotGenerated` and `MeetingsByDayGenerated` are properly typed to match your data structure. 33 | 34 | ::: code-group 35 | 36 | <<< ../.vitepress/components/react/SimpleAsyncExample.tsx 37 | 38 | ::: 39 | 40 | 44 | -------------------------------------------------------------------------------- /docs/vue-meeting-selector/slots.md: -------------------------------------------------------------------------------- 1 | # Slots 2 | 3 | The component exposes several slots to customize how buttons, headers, and meeting slots are rendered. All slots are typed and optional. 4 | 5 | ## Available Slots 6 | 7 | | Slot Name | Props Provided | Description | 8 | | ----------------- | ------------------------- | --------------------------------------------------------------------------- | 9 | | `meeting` | `{ meeting: MSlot }` | Renders a custom meeting slot. Use to customize the display of each slot. | 10 | | `header` | `{ meetings: MDay }` | Renders the header for each day group (e.g., day + month display). | 11 | | `button-previous` | `—` | Replaces the default "previous date" button. | 12 | | `button-next` | `—` | Replaces the default "next date" button. | 13 | | `button-up` | `{ isDisabled: boolean }` | Replaces the default "previous meeting group" button (vertical navigation). | 14 | | `button-down` | `{ isDisabled: boolean }` | Replaces the default "next meeting group" button (vertical navigation). | 15 | 16 | ## Example usage 17 | 18 | ```vue 19 | 20 |
{{ meeting.startAt }}
21 |
22 | 23 | 24 |

{{ new Date(meetings.date).toLocaleDateString() }}

25 |
26 | 27 | 28 | 31 | 32 | ``` 33 | -------------------------------------------------------------------------------- /packages/vue-meeting-selector/src/components/meetingSelector/DayDisplay.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { render, screen } from '@testing-library/vue'; 3 | import DayDisplay from './DayDisplay.vue'; 4 | import { MeetingSlot, MeetingsByDay } from 'common-meeting-selector'; 5 | 6 | describe('DayDisplay', () => { 7 | it('renders the correct title and subtitle based on the meetingsByDay prop', () => { 8 | const slots: MeetingSlot<'date'>[] = []; 9 | const meetingsByDay: MeetingsByDay<'date', 'slots', MeetingSlot<'date'>> = { 10 | date: '2025-08-10T12:00:00Z', 11 | slots, 12 | }; 13 | 14 | const daysLabel = [ 15 | 'Sunday', 16 | 'Monday', 17 | 'Tuesday', 18 | 'Wednesday', 19 | 'Thursday', 20 | 'Friday', 21 | 'Saturday', 22 | ]; 23 | const monthsLabel = [ 24 | 'January', 25 | 'February', 26 | 'March', 27 | 'April', 28 | 'May', 29 | 'June', 30 | 'July', 31 | 'August', 32 | 'September', 33 | 'October', 34 | 'November', 35 | 'December', 36 | ]; 37 | 38 | // Render the component 39 | render(DayDisplay, { 40 | props: { 41 | meetingsByDay: meetingsByDay as unknown as MeetingsByDay< 42 | string, 43 | string, 44 | MeetingSlot 45 | >, 46 | dateFieldKey: 'date', 47 | meetingSlotsKey: 'slots', 48 | daysLabel, 49 | monthsLabel, 50 | }, 51 | }); 52 | 53 | const titleElement = screen.getByText('Sunday'); 54 | expect(titleElement).toBeInTheDocument(); 55 | 56 | const subtitleElement = screen.getByText('10 August'); 57 | expect(subtitleElement).toBeInTheDocument(); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /packages/vue-meeting-selector/src/components/meetingSelector/MeetingsDisplay.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 54 | -------------------------------------------------------------------------------- /packages/react-meeting-selector/src/components/meetingSelector/DayDisplay.tsx: -------------------------------------------------------------------------------- 1 | import { type HTMLAttributes, memo, useMemo } from 'react'; 2 | import type { MeetingSlot, MeetingsByDay } from 'common-meeting-selector'; 3 | 4 | type DayDisplayProps< 5 | DateFieldKey extends string, 6 | MeetingSlotsKey extends string, 7 | MSlot extends MeetingSlot, 8 | MDay extends MeetingsByDay, 9 | > = { 10 | meetingsByDay: MDay; 11 | dateFieldKey: DateFieldKey; 12 | meetingSlotsKey: MeetingSlotsKey; 13 | daysLabel: string[]; 14 | monthsLabel: string[]; 15 | } & HTMLAttributes; 16 | 17 | const DayDisplayComponent = < 18 | DateFieldKey extends string, 19 | MeetingSlotsKey extends string, 20 | MSlot extends MeetingSlot, 21 | MDay extends MeetingsByDay, 22 | >({ 23 | meetingsByDay, 24 | dateFieldKey, 25 | daysLabel, 26 | monthsLabel, 27 | className, 28 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 29 | meetingSlotsKey, 30 | ...props 31 | }: DayDisplayProps) => { 32 | const date = useMemo(() => new Date(meetingsByDay[dateFieldKey]), [meetingsByDay, dateFieldKey]); 33 | 34 | const title = useMemo(() => { 35 | return daysLabel[date.getDay()]; 36 | }, [date, daysLabel]); 37 | 38 | const subtitle = useMemo(() => { 39 | return `${date.getDate()} ${monthsLabel[date.getMonth()]}`; 40 | }, [date, monthsLabel]); 41 | 42 | return ( 43 |
44 |
{title}
45 |
{subtitle}
46 |
47 | ); 48 | }; 49 | 50 | export const DayDisplay = memo(DayDisplayComponent) as typeof DayDisplayComponent; 51 | -------------------------------------------------------------------------------- /packages/react-meeting-selector/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-meeting-selector", 3 | "version": "1.0.1", 4 | "private": false, 5 | "type": "module", 6 | "main": "dist/react-meeting-selector.umd.js", 7 | "module": "dist/react-meeting-selector.es.js", 8 | "types": "dist/index.d.ts", 9 | "exports": { 10 | ".": { 11 | "types": "./dist/index.d.ts", 12 | "import": "./dist/react-meeting-selector.es.js", 13 | "require": "./dist/react-meeting-selector.umd.js" 14 | }, 15 | "./style.css": { 16 | "default": "./dist/main.css" 17 | } 18 | }, 19 | "scripts": { 20 | "dev": "run-s build dev:watch", 21 | "dev:watch": "chokidar 'src/**/*' -c 'pnpm build'", 22 | "build": "tsc -b && vite build", 23 | "lint": "eslint .", 24 | "test": "vitest", 25 | "coverage": "vitest run --coverage" 26 | }, 27 | "dependencies": { 28 | "common-meeting-selector": "workspace:*", 29 | "react": "catalog:", 30 | "react-dom": "catalog:" 31 | }, 32 | "devDependencies": { 33 | "@eslint/js": "catalog:", 34 | "@testing-library/dom": "^10.4.1", 35 | "@testing-library/jest-dom": "^6.6.4", 36 | "@testing-library/react": "^16.3.0", 37 | "@types/node": "catalog:", 38 | "@types/react": "catalog:", 39 | "@types/react-dom": "catalog:", 40 | "@vitejs/plugin-react": "^4.7.0", 41 | "@vitest/coverage-v8": "catalog:", 42 | "chokidar-cli": "catalog:", 43 | "eslint": "catalog:", 44 | "eslint-plugin-react-hooks": "^5.2.0", 45 | "eslint-plugin-react-refresh": "^0.4.20", 46 | "globals": "^16.3.0", 47 | "npm-run-all2": "catalog:", 48 | "prettier": "catalog:", 49 | "typescript": "catalog:", 50 | "typescript-eslint": "^8.35.1", 51 | "vite": "^7.2.6", 52 | "vite-plugin-dts": "^4.5.4", 53 | "vitest": "catalog:" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /docs/common-meeting-selector/generate-placeholder.md: -------------------------------------------------------------------------------- 1 | # generatePlaceHolder 2 | 3 | Generates a simplified list of days (excluding weekends), with **empty slot arrays** — useful for quickly mocking or displaying placeholder data in development or loading states. 4 | 5 | ## Use Case 6 | 7 | This utility is intended to create quick scaffolding for the `MeetingSelector` component, particularly when you don’t need actual time slots yet. It helps simulate availability structures without specifying real hours. 8 | 9 | ## Function Signature 10 | 11 | ```ts 12 | generatePlaceHolder(date: Date, nbDays: number): MeetingsByDayGenerated[] 13 | ``` 14 | 15 | ## Parameters 16 | 17 | | Name | Type | Description | 18 | | -------- | -------- | ---------------------------------------------------------- | 19 | | `date` | `Date` | The start date for generating placeholder days. | 20 | | `nbDays` | `number` | Number of **weekdays** to generate (weekends are skipped). | 21 | 22 | ## Returns 23 | 24 | ```ts 25 | type MeetingsByDayGenerated = { 26 | date: Date; 27 | slots: []; 28 | }; 29 | ``` 30 | 31 | An array of objects, each containing: 32 | 33 | - A `date` (skipping weekends) 34 | - An empty `slots array` — ready to be filled later or used as placeholder 35 | 36 | ## Behavior 37 | 38 | - Slot arrays are always empty ([]). 39 | - Dates are cloned to avoid mutation side-effects. 40 | 41 | ## Exemple 42 | 43 | ```ts 44 | import { generatePlaceHolder } from '-meeting-selector'; 45 | 46 | const placeholders = generatePlaceHolder(new Date(), 3); 47 | 48 | console.log(placeholders); 49 | /* 50 | [ 51 | { date: 2025-07-21T00:00:00.000Z, slots: [] }, 52 | { date: 2025-07-22T00:00:00.000Z, slots: [] }, 53 | { date: 2025-07-23T00:00:00.000Z, slots: [] }, 54 | ] 55 | */ 56 | ``` 57 | -------------------------------------------------------------------------------- /packages/react-meeting-selector/src/components/meetingSelector/MeetingDisplay.tsx: -------------------------------------------------------------------------------- 1 | import { type HTMLAttributes, memo } from 'react'; 2 | import type { MeetingSlot, MeetingsByDay } from 'common-meeting-selector'; 3 | import { MeetingSlotDisplay } from './MeetingSlotDisplay'; 4 | 5 | type MeetingDisplayProps< 6 | DateFieldKey extends string, 7 | MeetingSlotsKey extends string, 8 | MSlot extends MeetingSlot, 9 | MDay extends MeetingsByDay, 10 | > = { 11 | meetingsByDay: MDay; 12 | dateFieldKey: DateFieldKey; 13 | meetingSlotsKey: MeetingSlotsKey; 14 | loading?: boolean; 15 | handleMeetingSlotClick: (meetingSlot: MSlot) => void; 16 | meetingSlotSelected: MSlot | MSlot[] | null; 17 | renderMeeting?: (props: { meeting: MSlot; index: number }) => React.ReactNode; 18 | } & HTMLAttributes; 19 | 20 | const MeetingDisplayComponent = < 21 | DateFieldKey extends string, 22 | MeetingSlotsKey extends string, 23 | MSlot extends MeetingSlot, 24 | MDay extends MeetingsByDay, 25 | >({ 26 | meetingsByDay, 27 | dateFieldKey, 28 | meetingSlotsKey, 29 | loading = false, 30 | handleMeetingSlotClick, 31 | meetingSlotSelected, 32 | renderMeeting, 33 | className, 34 | ...props 35 | }: MeetingDisplayProps) => { 36 | return ( 37 |
38 | {meetingsByDay[meetingSlotsKey].map((slot, index) => 39 | renderMeeting ? ( 40 | renderMeeting({ meeting: slot, index }) 41 | ) : ( 42 | handleMeetingSlotClick(slot)} 49 | /> 50 | ), 51 | )} 52 |
53 | ); 54 | }; 55 | 56 | export const MeetingDisplay = memo(MeetingDisplayComponent) as typeof MeetingDisplayComponent; 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Meeting-selector 2 | 3 | This component is inspired from the meeting selector from [doctolib](https://www.doctolib.fr/medecin-generaliste/paris). 4 | 5 | - [github](https://github.com/IneoO/meeting-selector) 6 | - [doc](https://meeting-selector.tuturu.io) 7 | 8 | ## availables portages 9 | 10 | - React 19.x: [react-meeting-selector](https://github.com/IneoO/meeting-selector/blob/master/packages/react-meeting-selector/README.md) 11 | - Vue 3.x: [vue-meeting-selector](https://github.com/IneoO/meeting-selector/blob/master/packages/vue-meeting-selector/README.md) 12 | 13 | ## Project Setup 14 | 15 | This project is a **pnpm-based monorepo** containing both Vue and React implementations of the `meeting-selector` component, along with documentation, playgrounds, and shared utilities. 16 | 17 | To work on a specific part of the project: 18 | 19 | ### Development 20 | 21 | ```json 22 | "scripts": { 23 | "dev:vue-meeting-selector": "pnpm --filter vue-meeting-selector dev & pnpm --filter vue-meeting-selector-dev dev", 24 | "dev:react-meeting-selector": "pnpm --filter react-meeting-selector dev & pnpm --filter react-meeting-selector-dev dev", 25 | "dev:docs": "pnpm --filter doc-meeting-selector docs:dev", 26 | "lint": "pnpm -r run lint" 27 | } 28 | ``` 29 | 30 | ### Build 31 | 32 | ```json 33 | "scripts": { 34 | "build:vue-meeting-selector": "pnpm --filter vue-meeting-selector build", 35 | "build:react-meeting-selector": "pnpm --filter react-meeting-selector build", 36 | "build:docs": "pnpm --filter doc-meeting-selector docs:build" 37 | } 38 | ``` 39 | 40 | ### Monorepo Structure 41 | 42 | ```bash 43 | ├── packages/ 44 | │ ├── vue-meeting-selector/ # Vue 3 library (main export) 45 | │ ├── react-meeting-selector/ # React library (main export) 46 | │ └── common/ # Shared types, utilities, and generators 47 | │ 48 | ├── dev/ 49 | │ ├── vue-meeting-selector-dev/ # Vue playground & testing components 50 | │ └── react-meeting-selector-dev/ # React playground & testing components 51 | │ 52 | ├── docs/ # VitePress documentation site 53 | │ 54 | └── README.md 55 | ``` 56 | 57 | All packages are linked via `pnpm workspaces` and use local imports to share logic across Vue and React implementations 58 | -------------------------------------------------------------------------------- /packages/vue-meeting-selector/src/components/meetingSelector/MeetingSlotDisplay.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 71 | -------------------------------------------------------------------------------- /packages/vue-meeting-selector/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-meeting-selector", 3 | "version": "4.0.1", 4 | "private": false, 5 | "type": "module", 6 | "main": "dist/vue-meeting-selector.umd.js", 7 | "module": "dist/vue-meeting-selector.es.js", 8 | "types": "dist/index.d.ts", 9 | "exports": { 10 | ".": { 11 | "types": "./dist/index.d.ts", 12 | "import": "./dist/vue-meeting-selector.es.js", 13 | "require": "./dist/vue-meeting-selector.umd.js" 14 | }, 15 | "./style.css": { 16 | "default": "./dist/main.css" 17 | } 18 | }, 19 | "scripts": { 20 | "build": "run-p type-check \"build-only {@}\" --", 21 | "build-only": "vite build", 22 | "type-check": "vue-tsc --build", 23 | "dev": "run-s build dev:watch", 24 | "dev:watch": "chokidar 'src/**/*' -c 'pnpm build'", 25 | "test": "vitest", 26 | "coverage": "vitest run --coverage", 27 | "lint:oxlint": "oxlint . --fix -D correctness --ignore-path .gitignore", 28 | "lint:eslint": "eslint . --fix", 29 | "lint": "run-s lint:*", 30 | "format": "prettier --write src/" 31 | }, 32 | "dependencies": { 33 | "common-meeting-selector": "workspace:^", 34 | "vue": "catalog:" 35 | }, 36 | "devDependencies": { 37 | "@testing-library/jest-dom": "^6.6.4", 38 | "@testing-library/vue": "^8.1.0", 39 | "@tsconfig/node22": "^22.0.2", 40 | "@types/jsdom": "^21.1.7", 41 | "@types/node": "catalog:", 42 | "@vitejs/plugin-vue": "^6.0.0", 43 | "@vitest/coverage-v8": "catalog:", 44 | "@vitest/eslint-plugin": "^1.2.7", 45 | "@vue/eslint-config-prettier": "^10.2.0", 46 | "@vue/eslint-config-typescript": "^14.5.1", 47 | "@vue/test-utils": "^2.4.6", 48 | "@vue/tsconfig": "^0.7.0", 49 | "chokidar-cli": "catalog:", 50 | "eslint": "catalog:", 51 | "eslint-plugin-oxlint": "~1.1.0", 52 | "eslint-plugin-vue": "~10.2.0", 53 | "jiti": "^2.4.2", 54 | "jsdom": "^26.1.0", 55 | "npm-run-all2": "catalog:", 56 | "oxlint": "catalog:", 57 | "prettier": "catalog:", 58 | "sass-embedded": "^1.89.2", 59 | "typescript": "catalog:", 60 | "vite": "7.2.6", 61 | "vite-plugin-dts": "^4.5.4", 62 | "vite-plugin-vue-devtools": "^8.0.5", 63 | "vitest": "catalog:", 64 | "vue-tsc": "catalog:" 65 | }, 66 | "publishConfig": { 67 | "access": "public", 68 | "registry": "https://registry.npmjs.org" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /devs/react-meeting-selector-dev/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: 13 | 14 | ```js 15 | export default tseslint.config([ 16 | globalIgnores(['dist']), 17 | { 18 | files: ['**/*.{ts,tsx}'], 19 | extends: [ 20 | // Other configs... 21 | 22 | // Remove tseslint.configs.recommended and replace with this 23 | ...tseslint.configs.recommendedTypeChecked, 24 | // Alternatively, use this for stricter rules 25 | ...tseslint.configs.strictTypeChecked, 26 | // Optionally, add this for stylistic rules 27 | ...tseslint.configs.stylisticTypeChecked, 28 | 29 | // Other configs... 30 | ], 31 | languageOptions: { 32 | parserOptions: { 33 | project: ['./tsconfig.node.json', './tsconfig.app.json'], 34 | tsconfigRootDir: import.meta.dirname, 35 | }, 36 | // other options... 37 | }, 38 | }, 39 | ]) 40 | ``` 41 | 42 | You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: 43 | 44 | ```js 45 | // eslint.config.js 46 | import reactX from 'eslint-plugin-react-x' 47 | import reactDom from 'eslint-plugin-react-dom' 48 | 49 | export default tseslint.config([ 50 | globalIgnores(['dist']), 51 | { 52 | files: ['**/*.{ts,tsx}'], 53 | extends: [ 54 | // Other configs... 55 | // Enable lint rules for React 56 | reactX.configs['recommended-typescript'], 57 | // Enable lint rules for React DOM 58 | reactDom.configs.recommended, 59 | ], 60 | languageOptions: { 61 | parserOptions: { 62 | project: ['./tsconfig.node.json', './tsconfig.app.json'], 63 | tsconfigRootDir: import.meta.dirname, 64 | }, 65 | // other options... 66 | }, 67 | }, 68 | ]) 69 | ``` 70 | -------------------------------------------------------------------------------- /docs/common-meeting-selector/generate-meetings-by-days.md: -------------------------------------------------------------------------------- 1 | # generateDays 2 | 3 | Generates a list of days, each with time slots between a given start and end time, skipping weekends. 4 | 5 | ## Use Case 6 | 7 | This utility is designed to help create mock or real meetingsByDay data compatible with your MeetingSelector component — especially useful in development, testing, or preview environments. 8 | 9 | ## Function Signature 10 | 11 | ```typescript 12 | generateDays( 13 | date: Date, 14 | nbDays: number, 15 | startTime: Time, 16 | endTime: Time, 17 | interval: number 18 | ): MeetingsByDayGenerated[] 19 | ``` 20 | 21 | ## Parameters 22 | 23 | | Name | Type | Description | 24 | | ----------- | -------- | -------------------------------------------------------------------------- | 25 | | `date` | `Date` | The starting date for slot generation. | 26 | | `nbDays` | `number` | Number of **weekdays** to generate (weekends are skipped). | 27 | | `startTime` | `Time` | Time of day when the slots should start (e.g. `{ hours: 9, minutes: 0 }`). | 28 | | `endTime` | `Time` | Time of day when the slots should end. | 29 | | `interval` | `number` | Length of each slot in **minutes**. | 30 | 31 | ## Returns 32 | 33 | ```typescript 34 | type MeetingsByDayGenerated = { 35 | date: Date; 36 | slots: { 37 | date: Date; 38 | }[]; 39 | }; 40 | ``` 41 | 42 | An array of objects, each containing: 43 | 44 | - A date 45 | - An array of slot objects ({ date: Date }) between startTime and endTime spaced by interval 46 | 47 | ## Behavior 48 | 49 | - The first day starts immediately or from startTime, depending on the current time. 50 | - All time slots are rounded to the next closest interval. 51 | - Times are set with zero seconds and milliseconds for consistency. 52 | 53 | ## Example 54 | 55 | ```typescript 56 | import { generateDays } from '-meeting-selector'; 57 | 58 | const days = generateDays( 59 | new Date(), // today 60 | 5, // generate 5 weekdays 61 | { hours: 9, minutes: 0 }, // start at 09:00 62 | { hours: 17, minutes: 0 }, // end at 17:00 63 | 30 // 30-minute slots 64 | ); 65 | 66 | console.log(days); 67 | /* 68 | [ 69 | { 70 | date: 2025-07-16T00:00:00.000Z, 71 | slots: [ 72 | { date: 2025-07-16T09:00:00.000Z }, 73 | { date: 2025-07-16T09:30:00.000Z }, 74 | ... 75 | ] 76 | }, 77 | ... 78 | ] 79 | */ 80 | ``` 81 | 82 | ## Types 83 | 84 | ```typescript 85 | export type Time = { 86 | hours: number; 87 | minutes: number; 88 | }; 89 | 90 | export type MeetingSlotGenerated = { 91 | date: Date; 92 | }; 93 | 94 | export type MeetingsByDayGenerated = { 95 | date: Date; 96 | slots: MeetingSlotGenerated[]; 97 | }; 98 | ``` 99 | -------------------------------------------------------------------------------- /docs/vue-meeting-selector/props.md: -------------------------------------------------------------------------------- 1 | # Props 2 | 3 | This component displays meeting slots grouped by day. It is fully typed and supports advanced customization through props 4 | 5 | ## Props Table 6 | 7 | | Prop | Type | Default | Required | Description | 8 | | ----------------- | ------------------------------------- | ------- | -------- | ---------------------------------------------------------- | 9 | | `meetingsByDays` | `MDay[]` | — | true | List of grouped meeting slots by day. | 10 | | `dateFieldKey` | `DateFieldKey` | — | true | The key used to extract the slot date (e.g., `'startAt'`). | 11 | | `meetingSlotsKey` | `MeetingSlotsKey` | — | true | The key used to extract the list of slots of the day. | 12 | | `date` | `Date` | — | true | The currently selected or reference date. | 13 | | `modelValue` | `MSlot \| MSlot[] \| null` | — | true | Selected slot(s), used with `v-model`. | 14 | | `multi` | `boolean` | `false` | false | Enables multiple slot selection. | 15 | | `calendarOptions` | [`CalendarOptions`](#calendaroptions) | `{}` | false | Configuration options for calendar display. | 16 | | `loading` | `boolean` | `false` | false | Whether the calendar is in a loading state. | 17 | | `skip` | `number` | `-1` | false | Number of slot rows to skip. Useful for pagination. | 18 | 19 | ## Types 20 | 21 | ### `MeetingSlot` 22 | 23 | ```typescript 24 | export type MeetingSlot = { 25 | [K in MeetingDateKey]: string | Date; 26 | } & Record; 27 | ``` 28 | 29 | Represents a single meeting slot. It includes a dynamic date key (MeetingDateKey, such as "startAt" or "date"), and any other custom data. 30 | 31 | ### `MeetingsByDay` 32 | 33 | ```typescript 34 | export type MeetingsByDay< 35 | MeetingDateKey extends string, 36 | Slot extends MeetingSlot 37 | > = { 38 | [K in MeetingDateKey]: string | Date; 39 | } & Record; 40 | ``` 41 | 42 | Represents a day grouping multiple slots, each of which follows the MeetingSlot shape. 43 | 44 | ### `CalendarOptions` 45 | 46 | ```typescript 47 | export type CalendarOptions = { 48 | daysLabel?: string[]; // Labels for days of the week 49 | monthsLabel?: string[]; // Labels for months 50 | limit?: number; // Max number of days to display 51 | spacing?: number; // Gap between days 52 | disabledDate?: (date: string | Date) => boolean; // Disable specific dates 53 | }; 54 | ``` 55 | 56 | Use this to control calendar appearance, layout, and behavior. 57 | -------------------------------------------------------------------------------- /packages/react-meeting-selector/src/components/meetingSelector/MeetingSlotDisplay.tsx: -------------------------------------------------------------------------------- 1 | import type { MeetingSlot } from 'common-meeting-selector'; 2 | import { type HTMLAttributes, memo, useMemo } from 'react'; 3 | 4 | type MeetingSlotDisplayProps< 5 | DateFieldKey extends string, 6 | MSlot extends MeetingSlot, 7 | > = { 8 | meetingSlot: MSlot; 9 | dateFieldKey: DateFieldKey; 10 | loading?: boolean; 11 | meetingSlotSelected: MSlot | MSlot[] | null; 12 | handleMeetingSlotClick: (meetingSlot: MSlot) => void; 13 | } & HTMLAttributes; 14 | 15 | const MeetingSlotDisplayComponent = < 16 | DateFieldKey extends string, 17 | MSlot extends MeetingSlot, 18 | >({ 19 | meetingSlot, 20 | dateFieldKey, 21 | loading = false, 22 | meetingSlotSelected, 23 | handleMeetingSlotClick, 24 | className, 25 | ...props 26 | }: MeetingSlotDisplayProps) => { 27 | const time = useMemo(() => { 28 | const date = new Date(meetingSlot[dateFieldKey]); 29 | const hours = date.getHours() < 10 ? `0${date.getHours()}` : date.getHours(); 30 | const minutes = date.getMinutes() < 10 ? `0${date.getMinutes()}` : date.getMinutes(); 31 | return `${hours}:${minutes}`; 32 | }, [meetingSlot, dateFieldKey]); 33 | 34 | const isMeetingSelected = useMemo(() => { 35 | if (Array.isArray(meetingSlotSelected)) { 36 | const date: number = new Date(meetingSlot[dateFieldKey]).getTime(); 37 | for (const slot of meetingSlotSelected) { 38 | const d = new Date(slot[dateFieldKey]); 39 | if (d.getTime() === date) { 40 | return true; 41 | } 42 | } 43 | return false; 44 | } 45 | if (meetingSlotSelected?.[dateFieldKey]) { 46 | const meetingSelectedDate = new Date(meetingSlotSelected[dateFieldKey]); 47 | const meetingDate = new Date(meetingSlot[dateFieldKey]); 48 | return meetingSelectedDate.getTime() === meetingDate.getTime(); 49 | } 50 | return false; 51 | }, [meetingSlot, dateFieldKey, meetingSlotSelected]); 52 | 53 | const meetingClass = useMemo(() => { 54 | const css: string[] = []; 55 | if (isMeetingSelected) { 56 | css.push('meeting-selector__btn--selected'); 57 | } 58 | if (loading) { 59 | css.push('meeting-selector__btn-loading'); 60 | } 61 | return css.join(' '); 62 | }, [isMeetingSelected, loading]); 63 | 64 | return ( 65 |
66 | {meetingSlot.date ? ( 67 | 75 | ) : ( 76 |
77 | )} 78 |
79 | ); 80 | }; 81 | 82 | export const MeetingSlotDisplay = memo( 83 | MeetingSlotDisplayComponent, 84 | ) as typeof MeetingSlotDisplayComponent; 85 | -------------------------------------------------------------------------------- /devs/react-meeting-selector-dev/src/components/SimpleExample.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | MeetingSelector, 4 | generateMeetingsByDays, 5 | type MeetingsByDayGenerated, 6 | type MeetingSlotGenerated, 7 | type Time, 8 | } from 'react-meeting-selector'; 9 | import 'react-meeting-selector/style.css'; 10 | 11 | export const SimpleExample = () => { 12 | const [date, setDate] = React.useState(new Date()); 13 | const initialDateRef = React.useRef(date); 14 | const [skip, setSkip] = React.useState(0); 15 | const [value, setValue] = React.useState(null); 16 | 17 | const [meetingsDays, setMeetingsDays] = React.useState([]); 18 | 19 | const nbDaysToDisplay = 5; 20 | 21 | const handleChange = React.useCallback((val: MeetingSlotGenerated | null) => { 22 | setValue(val); 23 | }, []); 24 | 25 | const nextDate = React.useCallback(async () => { 26 | const start: Time = { 27 | hours: 8, 28 | minutes: 0, 29 | }; 30 | const end: Time = { 31 | hours: 16, 32 | minutes: 0, 33 | }; 34 | const dateCopy = new Date(date); 35 | const newDate = new Date(dateCopy.setDate(dateCopy.getDate() + 7)); 36 | setDate(newDate); 37 | setMeetingsDays(generateMeetingsByDays(newDate, nbDaysToDisplay, start, end, 30)); 38 | }, [date]); 39 | 40 | const previousDate = React.useCallback(async () => { 41 | const start: Time = { 42 | hours: 8, 43 | minutes: 0, 44 | }; 45 | const end: Time = { 46 | hours: 16, 47 | minutes: 0, 48 | }; 49 | const dateCopy = new Date(date); 50 | dateCopy.setDate(dateCopy.getDate() - 7); 51 | const formattingDate = (dateToFormat: Date) => { 52 | const d = new Date(dateToFormat); 53 | const day = d.getDate() < 10 ? `0${d.getDate()}` : d.getDate(); 54 | const month = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : d.getMonth() + 1; 55 | const year = d.getFullYear(); 56 | return `${year}-${month}-${day}`; 57 | }; 58 | const newDate = 59 | formattingDate(new Date()) >= formattingDate(dateCopy) ? new Date() : new Date(dateCopy); 60 | setDate(newDate); 61 | setMeetingsDays(generateMeetingsByDays(newDate, nbDaysToDisplay, start, end, 30)); 62 | }, [date]); 63 | 64 | React.useEffect(() => { 65 | (async () => { 66 | const start: Time = { 67 | hours: 8, 68 | minutes: 0, 69 | }; 70 | const end: Time = { 71 | hours: 16, 72 | minutes: 0, 73 | }; 74 | setMeetingsDays( 75 | generateMeetingsByDays(initialDateRef.current, nbDaysToDisplay, start, end, 30) 76 | ); 77 | })(); 78 | }, []); 79 | 80 | return ( 81 | <> 82 | 94 | meetingSlot: {JSON.stringify(value) ?? ''} 95 | 96 | ); 97 | }; 98 | -------------------------------------------------------------------------------- /devs/vue-meeting-selector-dev/src/components/SimpleExample.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 90 | 91 | 109 | -------------------------------------------------------------------------------- /packages/common/src/helpers/meetingsByDayGenerator.ts: -------------------------------------------------------------------------------- 1 | import type { Time } from '../types/meetingsByDayGenerator.type'; 2 | import type { 3 | MeetingSlotGenerated, 4 | MeetingsByDayGenerated, 5 | } from '../types/meetingsByDayGenerator.type'; 6 | 7 | const setTimeUTC = (date: Date, time: Time) => { 8 | return new Date( 9 | Date.UTC( 10 | date.getUTCFullYear(), 11 | date.getUTCMonth(), 12 | date.getUTCDate(), 13 | time.hours, 14 | time.minutes, 15 | 0, 16 | 0 17 | ) 18 | ); 19 | }; 20 | 21 | const isSameUTCDate = (a: Date, b: Date) => 22 | a.getUTCFullYear() === b.getUTCFullYear() && 23 | a.getUTCMonth() === b.getUTCMonth() && 24 | a.getUTCDate() === b.getUTCDate(); 25 | 26 | const roundUpToInterval = (dt: Date, intervalMinutes: number) => { 27 | const ms = dt.getTime(); 28 | const step = intervalMinutes * 60_000; 29 | const rounded = ms % step === 0 ? ms : Math.ceil(ms / step) * step; 30 | return new Date(rounded); 31 | }; 32 | 33 | const generateMeetingSlots = (start: Date, end: Date, interval: number) => { 34 | let startStamp: number = start.getTime(); 35 | const endStamp: number = end.getTime(); 36 | const slots: MeetingSlotGenerated[] = []; 37 | for (; startStamp <= endStamp; startStamp += interval * 60000) { 38 | const slot: MeetingSlotGenerated = { 39 | date: new Date(startStamp), 40 | }; 41 | slots.push(slot); 42 | } 43 | return slots; 44 | }; 45 | 46 | const generateFirstDate = (date: Date, interval: number, startTime: Time, endTime: Time) => { 47 | const now = new Date(); 48 | const dayStart = setTimeUTC(date, startTime); 49 | const dayEnd = setTimeUTC(date, endTime); 50 | 51 | let start = dayStart; 52 | if (isSameUTCDate(date, now) && dayStart.getTime() < now.getTime()) { 53 | start = roundUpToInterval(now, interval); 54 | } 55 | 56 | const slots = 57 | start.getTime() > dayEnd.getTime() ? [] : generateMeetingSlots(start, dayEnd, interval); 58 | 59 | return { date, slots }; 60 | }; 61 | 62 | export const generateMeetingsByDays = ( 63 | date: Date, 64 | nbDays: number, 65 | startTime: Time, 66 | endTime: Time, 67 | interval: number 68 | ): MeetingsByDayGenerated[] => { 69 | const days: MeetingsByDayGenerated[] = []; 70 | days.push(generateFirstDate(date, interval, startTime, endTime)); 71 | 72 | let cursor = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); 73 | 74 | for (let i = 1; i < nbDays; i++) { 75 | cursor = new Date(cursor.getTime() + 24 * 60 * 60 * 1000); 76 | const start = setTimeUTC(cursor, startTime); 77 | const end = setTimeUTC(cursor, endTime); 78 | const slots = start.getTime() > end.getTime() ? [] : generateMeetingSlots(start, end, interval); 79 | 80 | days.push({ date: new Date(cursor), slots }); 81 | } 82 | 83 | return days; 84 | }; 85 | 86 | export const generatePlaceHolder = (date: Date, nbDays: number): MeetingsByDayGenerated[] => { 87 | const out: MeetingsByDayGenerated[] = []; 88 | let cursor = new Date( 89 | Date.UTC( 90 | date.getUTCFullYear(), 91 | date.getUTCMonth(), 92 | date.getUTCDate(), 93 | date.getUTCHours(), 94 | date.getUTCMinutes(), 95 | date.getUTCSeconds(), 96 | date.getUTCMilliseconds() 97 | ) 98 | ); 99 | for (let i = 0; i < nbDays; i++) { 100 | out.push({ date: new Date(cursor), slots: [] }); 101 | cursor = new Date(cursor.getTime() + 24 * 60 * 60 * 1000); 102 | } 103 | return out; 104 | }; 105 | -------------------------------------------------------------------------------- /devs/vue-meeting-selector-dev/src/components/SimpleAsyncExample.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 115 | 116 | 134 | -------------------------------------------------------------------------------- /devs/vue-meeting-selector-dev/src/components/MultiExample.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 117 | 118 | 136 | -------------------------------------------------------------------------------- /devs/react-meeting-selector-dev/src/components/SimpleAsyncExample.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | MeetingSelector, 4 | generateMeetingsByDays, 5 | generatePlaceHolder, 6 | type MeetingsByDayGenerated, 7 | type MeetingSlotGenerated, 8 | type Time, 9 | } from 'react-meeting-selector'; 10 | import 'react-meeting-selector/style.css'; 11 | 12 | export const SimpleAsyncExample = () => { 13 | const nbDaysToDisplay = 5; 14 | 15 | const [date, setDate] = React.useState(new Date()); 16 | const initialDateRef = React.useRef(date); 17 | const [skip, setSkip] = React.useState(0); 18 | const [value, setValue] = React.useState(null); 19 | const [loading, setLoading] = React.useState(false); 20 | const [meetingsDays, setMeetingsDays] = React.useState( 21 | generatePlaceHolder(date, nbDaysToDisplay) 22 | ); 23 | 24 | const handleChange = React.useCallback((val: MeetingSlotGenerated | null) => { 25 | setValue(val); 26 | }, []); 27 | 28 | const generateMeetingsByDaysAsync = React.useCallback( 29 | ( 30 | d: Date, 31 | n: number, 32 | start: Time, 33 | end: Time, 34 | timesBetween: number 35 | ): Promise> => 36 | new Promise((resolve) => { 37 | setTimeout(() => { 38 | resolve(generateMeetingsByDays(d, n, start, end, timesBetween)); 39 | }, 1000); 40 | }), 41 | [] 42 | ); 43 | 44 | const nextDate = React.useCallback(async () => { 45 | setLoading(true); 46 | const start: Time = { 47 | hours: 8, 48 | minutes: 0, 49 | }; 50 | const end: Time = { 51 | hours: 16, 52 | minutes: 0, 53 | }; 54 | const dateCopy = new Date(date); 55 | const newDate = new Date(dateCopy.setDate(dateCopy.getDate() + 7)); 56 | setDate(newDate); 57 | setMeetingsDays(await generateMeetingsByDaysAsync(newDate, nbDaysToDisplay, start, end, 30)); 58 | setLoading(false); 59 | }, [date, generateMeetingsByDaysAsync]); 60 | 61 | const previousDate = React.useCallback(async () => { 62 | setLoading(true); 63 | const start: Time = { 64 | hours: 8, 65 | minutes: 0, 66 | }; 67 | const end: Time = { 68 | hours: 16, 69 | minutes: 0, 70 | }; 71 | const dateCopy = new Date(date); 72 | dateCopy.setDate(dateCopy.getDate() - 7); 73 | const formattingDate = (dateToFormat: Date) => { 74 | const d = new Date(dateToFormat); 75 | const day = d.getDate() < 10 ? `0${d.getDate()}` : d.getDate(); 76 | const month = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : d.getMonth() + 1; 77 | const year = d.getFullYear(); 78 | return `${year}-${month}-${day}`; 79 | }; 80 | const newDate = 81 | formattingDate(new Date()) >= formattingDate(dateCopy) ? new Date() : new Date(dateCopy); 82 | setDate(newDate); 83 | setMeetingsDays(await generateMeetingsByDaysAsync(newDate, nbDaysToDisplay, start, end, 30)); 84 | setLoading(false); 85 | }, [date, generateMeetingsByDaysAsync]); 86 | 87 | React.useEffect(() => { 88 | setLoading(true); 89 | (async () => { 90 | const start: Time = { 91 | hours: 8, 92 | minutes: 0, 93 | }; 94 | const end: Time = { 95 | hours: 16, 96 | minutes: 0, 97 | }; 98 | setMeetingsDays( 99 | await generateMeetingsByDaysAsync(initialDateRef.current, nbDaysToDisplay, start, end, 30) 100 | ); 101 | setLoading(false); 102 | })(); 103 | }, [generateMeetingsByDaysAsync]); 104 | 105 | return ( 106 | <> 107 | 120 | meetingSlot: {JSON.stringify(value) ?? ''} 121 | 122 | ); 123 | }; 124 | -------------------------------------------------------------------------------- /devs/react-meeting-selector-dev/src/components/MultiExample.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | MeetingSelector, 4 | generateMeetingsByDays, 5 | generatePlaceHolder, 6 | type MeetingsByDayGenerated, 7 | type MeetingSlotGenerated, 8 | type Time, 9 | } from 'react-meeting-selector'; 10 | import 'react-meeting-selector/style.css'; 11 | 12 | export const MultiExample = () => { 13 | const nbDaysToDisplay = 5; 14 | 15 | const [date, setDate] = React.useState(new Date()); 16 | const initialDateRef = React.useRef(date); 17 | const [skip, setSkip] = React.useState(0); 18 | const [value, setValue] = React.useState([]); 19 | const [loading, setLoading] = React.useState(false); 20 | const [meetingsDays, setMeetingsDays] = React.useState( 21 | generatePlaceHolder(date, nbDaysToDisplay) 22 | ); 23 | 24 | const handleChange = React.useCallback((val: MeetingSlotGenerated[]) => { 25 | setValue(val); 26 | }, []); 27 | 28 | const generateMeetingsByDaysAsync = React.useCallback( 29 | ( 30 | d: Date, 31 | n: number, 32 | start: Time, 33 | end: Time, 34 | timesBetween: number 35 | ): Promise> => 36 | new Promise((resolve) => { 37 | setTimeout(() => { 38 | resolve(generateMeetingsByDays(d, n, start, end, timesBetween)); 39 | }, 1000); 40 | }), 41 | [] 42 | ); 43 | 44 | const nextDate = React.useCallback(async () => { 45 | setLoading(true); 46 | const start: Time = { 47 | hours: 8, 48 | minutes: 0, 49 | }; 50 | const end: Time = { 51 | hours: 16, 52 | minutes: 0, 53 | }; 54 | const dateCopy = new Date(date); 55 | const newDate = new Date(dateCopy.setDate(dateCopy.getDate() + 7)); 56 | setDate(newDate); 57 | setMeetingsDays(await generateMeetingsByDaysAsync(newDate, nbDaysToDisplay, start, end, 30)); 58 | setLoading(false); 59 | }, [date, generateMeetingsByDaysAsync]); 60 | 61 | const previousDate = React.useCallback(async () => { 62 | setLoading(true); 63 | const start: Time = { 64 | hours: 8, 65 | minutes: 0, 66 | }; 67 | const end: Time = { 68 | hours: 16, 69 | minutes: 0, 70 | }; 71 | const dateCopy = new Date(date); 72 | dateCopy.setDate(dateCopy.getDate() - 7); 73 | const formattingDate = (dateToFormat: Date) => { 74 | const d = new Date(dateToFormat); 75 | const day = d.getDate() < 10 ? `0${d.getDate()}` : d.getDate(); 76 | const month = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : d.getMonth() + 1; 77 | const year = d.getFullYear(); 78 | return `${year}-${month}-${day}`; 79 | }; 80 | const newDate = 81 | formattingDate(new Date()) >= formattingDate(dateCopy) ? new Date() : new Date(dateCopy); 82 | setDate(newDate); 83 | setMeetingsDays(await generateMeetingsByDaysAsync(newDate, nbDaysToDisplay, start, end, 30)); 84 | setLoading(false); 85 | }, [date, generateMeetingsByDaysAsync]); 86 | 87 | React.useEffect(() => { 88 | setLoading(true); 89 | (async () => { 90 | const start: Time = { 91 | hours: 8, 92 | minutes: 0, 93 | }; 94 | const end: Time = { 95 | hours: 16, 96 | minutes: 0, 97 | }; 98 | setMeetingsDays( 99 | await generateMeetingsByDaysAsync(initialDateRef.current, nbDaysToDisplay, start, end, 30) 100 | ); 101 | setLoading(false); 102 | })(); 103 | }, [generateMeetingsByDaysAsync]); 104 | 105 | return ( 106 | <> 107 | 108 | value={value} 109 | date={date} 110 | skip={skip} 111 | multi={true} 112 | loading={loading} 113 | handleValueChange={handleChange} 114 | meetingsByDays={meetingsDays} 115 | dateFieldKey="date" 116 | meetingSlotsKey="slots" 117 | handleNextDate={nextDate} 118 | handlePreviousDate={previousDate} 119 | handleSkipChange={setSkip} 120 | /> 121 | meetingSlot: {JSON.stringify(value) ?? ''} 122 | 123 | ); 124 | }; 125 | -------------------------------------------------------------------------------- /packages/common/src/assets/style/meeting-selector.scss: -------------------------------------------------------------------------------- 1 | .meeting-selector { 2 | --btn-bg: #dff1ff; 3 | --btn-bg-hover: #b0d7f4; 4 | --btn-bg-disabled: #adb5bd; 5 | --btn-text: #131516; 6 | --btn-text-disabled: #f8f9fa; 7 | --btn-border: transparent; 8 | --btn-border-focus: #b0d7f4; 9 | --btn-focus-ring: 0 0 0 2px black; 10 | --btn-radius: 0.375rem; 11 | --btn-padding: 0.375rem 0.375rem; 12 | --btn-font-weight: 600; 13 | --grid-border-color: #ebeef5; 14 | --btn-icon-disabled: #adb5bd; 15 | --btn-size: 2rem; 16 | --icon-size: 1.5rem; 17 | --btn-color-empty: #ebeef5; 18 | --loading-color-light: #e5e7eb; 19 | --loading-color-dark: #d1d5db; 20 | --font-size: 16px; 21 | font-size: var(--font-size); 22 | &__btn { 23 | all: unset; 24 | position: relative; 25 | background-color: var(--btn-bg); 26 | color: var(--btn-text); 27 | border: 1px solid var(--btn-border); 28 | padding: var(--btn-padding); 29 | border-radius: var(--btn-radius); 30 | font-weight: var(--btn-font-weight); 31 | font-size: 1rem; 32 | cursor: pointer; 33 | transition: background-color 0.2s ease, box-shadow 0.2s ease; 34 | &:hover:not(:disabled) { 35 | background-color: var(--btn-bg-hover); 36 | } 37 | 38 | &:focus-visible { 39 | box-shadow: var(--btn-focus-ring); 40 | } 41 | 42 | &:disabled { 43 | background-color: var(--btn-bg-disabled); 44 | color: var(--btn-text-disabled); 45 | cursor: not-allowed; 46 | opacity: 0.65; 47 | } 48 | 49 | &--selected { 50 | background-color: var(--btn-bg-hover); 51 | color: var(--btn-text); 52 | } 53 | } 54 | 55 | &__btn-icon { 56 | all: unset; 57 | position: relative; 58 | display: inline-flex; 59 | align-items: center; 60 | justify-content: center; 61 | width: var(--btn-size); 62 | height: var(--btn-size); 63 | color: var(--btn-bg); 64 | border-radius: var(--btn-radius); 65 | transition: color 0.2s ease, box-shadow 0.2s ease; 66 | cursor: pointer; 67 | &:hover:not(:disabled) { 68 | color: var(--btn-bg-hover); 69 | } 70 | 71 | &:focus-visible { 72 | box-shadow: var(--btn-focus-ring); 73 | } 74 | 75 | &:disabled { 76 | color: var(--btn-icon-disabled); 77 | cursor: not-allowed; 78 | opacity: 0.6; 79 | } 80 | 81 | &--loading { 82 | color: var(--loading-color-light); 83 | } 84 | } 85 | 86 | &__btn-loading { 87 | &::after { 88 | content: ''; 89 | position: absolute; 90 | inset: 0; 91 | z-index: 1; 92 | border-radius: var(--btn-radius); 93 | background: linear-gradient( 94 | 120deg, 95 | var(--loading-color-light) 0%, 96 | var(--loading-color-dark) 50%, 97 | var(--loading-color-light) 100% 98 | ); 99 | background-size: 200% 100%; 100 | animation: multi-select-shimmer 1.2s linear infinite; 101 | } 102 | } 103 | 104 | @keyframes multi-select-shimmer { 105 | 0% { 106 | background-position: -200% 0; 107 | } 108 | 100% { 109 | background-position: 200% 0; 110 | } 111 | } 112 | 113 | &__btn-empty { 114 | font-size: 1.5rem; 115 | border-radius: var(--btn-radius); 116 | padding: var(--btn-padding); 117 | font-weight: 700; 118 | color: var(--btn-color-empty); 119 | } 120 | 121 | &__tab { 122 | display: inline-flex; 123 | } 124 | &__pagination { 125 | display: flex; 126 | flex-direction: column; 127 | gap: 0.5rem; 128 | } 129 | &__days { 130 | display: flex; 131 | flex: 1; 132 | padding: 0 0.375rem; 133 | gap: 0.375rem; 134 | border-bottom: 1px solid var(--grid-border-color); 135 | } 136 | &__day { 137 | flex: 1 1 0; 138 | text-align: center; 139 | padding-bottom: 0.375rem; 140 | display: flex; 141 | flex-direction: column; 142 | gap: 0.375rem; 143 | } 144 | &__meetings { 145 | display: flex; 146 | flex-direction: column; 147 | gap: 0.375rem; 148 | } 149 | 150 | &__icon { 151 | font-size: var(--icon-size); 152 | height: var(--icon-size); 153 | width: var(--icon-size); 154 | } 155 | 156 | .day { 157 | color: var(--btn-text); 158 | &__title { 159 | font-size: 16px; 160 | font-weight: 700; 161 | } 162 | &__subtitle { 163 | font-size: 14px; 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /packages/vue-meeting-selector/src/components/meetingSelector/MeetingSlotDisplay.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeAll } from 'vitest'; 2 | import { render, screen, fireEvent } from '@testing-library/vue'; 3 | import MeetingSlotDisplay from './MeetingSlotDisplay.vue'; 4 | 5 | beforeAll(() => { 6 | process.env.TZ = 'UTC'; 7 | }); 8 | 9 | describe('MeetingSlotDisplay', () => { 10 | it('renders HH:mm based on meetingSlot[dateFieldKey]', () => { 11 | const meeting = { date: '2025-08-10T12:00:00Z' }; 12 | render(MeetingSlotDisplay, { 13 | props: { 14 | meetingSlot: meeting, 15 | dateFieldKey: 'date', 16 | loading: false, 17 | meetingSlotSelected: null, 18 | }, 19 | }); 20 | expect(screen.getByRole('button', { name: '12:00' })).toBeInTheDocument(); 21 | }); 22 | 23 | it('calls handleMeetingSlotClick when clicked', async () => { 24 | const meeting = { date: '2025-08-10T08:05:00Z' }; 25 | 26 | const { emitted } = render(MeetingSlotDisplay, { 27 | props: { 28 | meetingSlot: meeting, 29 | dateFieldKey: 'date', 30 | loading: false, 31 | meetingSlotSelected: null, 32 | }, 33 | }); 34 | 35 | await fireEvent.click(screen.getByRole('button', { name: '08:05' })); 36 | 37 | const ev = emitted()['meeting-slot-click']; 38 | expect(ev).toBeTruthy(); 39 | expect(ev!.length).toBe(1); 40 | expect(ev![0]).toEqual([meeting]); 41 | }); 42 | 43 | it('adds selected class when meetingSlotSelected matches (single object)', () => { 44 | const meeting = { date: '2025-08-10T09:30:00Z' }; 45 | 46 | const { container } = render(MeetingSlotDisplay, { 47 | props: { 48 | meetingSlot: meeting, 49 | dateFieldKey: 'date', 50 | loading: false, 51 | meetingSlotSelected: { date: '2025-08-10T09:30:00Z' }, 52 | }, 53 | }); 54 | 55 | const btn = screen.getByRole('button', { name: '09:30' }); 56 | expect(btn).toBeInTheDocument(); 57 | expect(container.querySelector('.meeting-selector__btn--selected')).toBeTruthy(); 58 | }); 59 | 60 | it('adds selected class when meetingSlotSelected array contains the slot', () => { 61 | const meeting = { date: '2025-08-10T10:00:00Z' }; 62 | 63 | const { container } = render(MeetingSlotDisplay, { 64 | props: { 65 | meetingSlot: meeting, 66 | dateFieldKey: 'date', 67 | loading: false, 68 | meetingSlotSelected: [{ date: '2025-08-10T09:00:00Z' }, { date: '2025-08-10T10:00:00Z' }], 69 | }, 70 | }); 71 | 72 | expect(screen.getByRole('button', { name: '10:00' })).toBeInTheDocument(); 73 | expect(container.querySelector('.meeting-selector__btn--selected')).toBeTruthy(); 74 | }); 75 | 76 | it('adds loading class and disables the button when loading=true', () => { 77 | const meeting = { date: '2025-08-10T07:00:00Z' }; 78 | 79 | const { container } = render(MeetingSlotDisplay, { 80 | props: { 81 | meetingSlot: meeting, 82 | dateFieldKey: 'date', 83 | loading: true, 84 | meetingSlotSelected: null, 85 | }, 86 | }); 87 | 88 | const btn = screen.getByRole('button', { name: '07:00' }); 89 | expect(btn).toBeDisabled(); 90 | expect(container.querySelector('.meeting-selector__btn-loading')).toBeTruthy(); 91 | }); 92 | 93 | it('renders the empty state when meetingSlot.date is falsy', () => { 94 | const meeting = { date: '' }; 95 | 96 | render(MeetingSlotDisplay, { 97 | props: { 98 | meetingSlot: meeting, 99 | dateFieldKey: 'date', 100 | loading: false, 101 | meetingSlotSelected: null, 102 | }, 103 | }); 104 | 105 | expect(screen.queryByRole('button')).not.toBeInTheDocument(); 106 | expect(screen.getByText('—')).toBeInTheDocument(); 107 | }); 108 | 109 | it('does NOT add selected class when a different meeting is selected (single object)', () => { 110 | const meetingRendered = { date: '2025-08-10T11:00:00Z' }; 111 | const selectedDifferent = [{ date: '2025-08-10T11:30:00Z' }]; 112 | 113 | const { container } = render(MeetingSlotDisplay, { 114 | props: { 115 | meetingSlot: meetingRendered, 116 | dateFieldKey: 'date', 117 | loading: false, 118 | meetingSlotSelected: selectedDifferent, 119 | }, 120 | }); 121 | 122 | const btn = screen.getByRole('button', { name: '11:00' }); 123 | expect(btn).toBeInTheDocument(); 124 | expect(container.querySelector('.meeting-selector__btn--selected')).toBeFalsy(); 125 | }); 126 | }); 127 | -------------------------------------------------------------------------------- /docs/.vitepress/config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitepress'; 2 | 3 | // https://vitepress.dev/reference/site-config 4 | export default defineConfig({ 5 | title: 'meeting-selector', 6 | description: 'Documentation for meeting-selector', 7 | themeConfig: { 8 | // https://vitepress.dev/reference/default-theme-config 9 | nav: [ 10 | { text: 'Home', link: '/' }, 11 | { 12 | text: 'vue-meeting-selector', 13 | link: '/vue-meeting-selector/installation', 14 | }, 15 | { 16 | text: 'react-meeting-selector', 17 | link: '/react-meeting-selector/installation', 18 | }, 19 | ], 20 | 21 | sidebar: [ 22 | { 23 | text: 'Introduction', 24 | link: '/introduction', 25 | }, 26 | { 27 | text: 'Common', 28 | items: [ 29 | { 30 | text: 'Generate meetings by days', 31 | link: '/common-meeting-selector/generate-meetings-by-days', 32 | }, 33 | { 34 | text: 'Generate placeholder', 35 | link: '/common-meeting-selector/generate-placeholder', 36 | }, 37 | ], 38 | }, 39 | { 40 | text: 'react-meeting-selector', 41 | items: [ 42 | { 43 | text: 'Getting Started', 44 | items: [ 45 | { 46 | text: 'Installation', 47 | link: '/react-meeting-selector/installation', 48 | }, 49 | { 50 | text: "What's new", 51 | link: '/react-meeting-selector/whats-new', 52 | }, 53 | ], 54 | }, 55 | { 56 | text: 'Documentation', 57 | items: [ 58 | { 59 | text: 'Props', 60 | link: '/react-meeting-selector/props', 61 | }, 62 | ], 63 | }, 64 | { 65 | text: 'Examples', 66 | items: [ 67 | { 68 | text: 'Simple example', 69 | link: '/react-meeting-selector/simple-example', 70 | }, 71 | { 72 | text: 'Simple async Example', 73 | link: '/react-meeting-selector/simple-async-example', 74 | }, 75 | { 76 | text: 'Multi example', 77 | link: '/react-meeting-selector/multi-example', 78 | }, 79 | { 80 | text: 'Multiple renders elements example', 81 | link: '/react-meeting-selector/multiple-renders-elements-example', 82 | }, 83 | ], 84 | }, 85 | ], 86 | }, 87 | { 88 | text: 'vue-meeting-selector', 89 | items: [ 90 | { 91 | text: 'Getting Started', 92 | items: [ 93 | { 94 | text: 'Installation', 95 | link: '/vue-meeting-selector/installation', 96 | }, 97 | { 98 | text: "What's new", 99 | link: '/vue-meeting-selector/whats-new', 100 | }, 101 | ], 102 | }, 103 | { 104 | text: 'Documentation', 105 | items: [ 106 | { 107 | text: 'Props', 108 | link: '/vue-meeting-selector/props', 109 | }, 110 | { 111 | text: 'Events', 112 | link: '/vue-meeting-selector/events', 113 | }, 114 | { 115 | text: 'Slots', 116 | link: '/vue-meeting-selector/slots', 117 | }, 118 | ], 119 | }, 120 | { 121 | text: 'Examples', 122 | items: [ 123 | { 124 | text: 'Simple example', 125 | link: '/vue-meeting-selector/simple-example', 126 | }, 127 | { 128 | text: 'Simple async Example', 129 | link: '/vue-meeting-selector/simple-async-example', 130 | }, 131 | { 132 | text: 'Multi example', 133 | link: '/vue-meeting-selector/multi-example', 134 | }, 135 | { 136 | text: 'Slots example', 137 | link: '/vue-meeting-selector/slots-example', 138 | }, 139 | ], 140 | }, 141 | ], 142 | }, 143 | ], 144 | 145 | socialLinks: [{ icon: 'github', link: 'https://github.com/ineoo/meeting-selector' }], 146 | }, 147 | }); 148 | -------------------------------------------------------------------------------- /packages/react-meeting-selector/src/components/meetingSelector/MeetingSlotDisplay.spec.tsx: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeAll } from 'vitest'; 2 | import { render, screen, fireEvent } from '@testing-library/react'; 3 | import { MeetingSlotDisplay } from './MeetingSlotDisplay'; 4 | 5 | beforeAll(() => { 6 | process.env.TZ = 'UTC'; 7 | }); 8 | 9 | describe('MeetingSlotDisplay', () => { 10 | it('renders HH:mm based on meetingSlot[dateFieldKey]', () => { 11 | const meeting = { date: '2025-08-10T12:00:00Z' }; 12 | 13 | render( 14 | {}} 20 | />, 21 | ); 22 | 23 | expect(screen.getByRole('button', { name: '12:00' })).toBeInTheDocument(); 24 | }); 25 | 26 | it('calls handleMeetingSlotClick when clicked', () => { 27 | const meeting = { date: '2025-08-10T08:05:00Z' }; 28 | const onClick = vi.fn(); 29 | 30 | render( 31 | , 38 | ); 39 | 40 | fireEvent.click(screen.getByRole('button', { name: '08:05' })); 41 | expect(onClick).toHaveBeenCalledTimes(1); 42 | expect(onClick).toHaveBeenCalledWith(meeting); 43 | }); 44 | 45 | it('adds selected class when meetingSlotSelected matches (single object)', () => { 46 | const meeting = { date: '2025-08-10T09:30:00Z' }; 47 | 48 | const { container } = render( 49 | {}} 55 | />, 56 | ); 57 | 58 | const btn = screen.getByRole('button', { name: '09:30' }); 59 | expect(btn).toBeInTheDocument(); 60 | expect(container.querySelector('.meeting-selector__btn--selected')).toBeTruthy(); 61 | }); 62 | 63 | it('adds selected class when meetingSlotSelected array contains the slot', () => { 64 | const meeting = { date: '2025-08-10T10:00:00Z' }; 65 | 66 | const { container } = render( 67 | {}} 73 | />, 74 | ); 75 | 76 | expect(screen.getByRole('button', { name: '10:00' })).toBeInTheDocument(); 77 | expect(container.querySelector('.meeting-selector__btn--selected')).toBeTruthy(); 78 | }); 79 | 80 | it('adds loading class and disables the button when loading=true', () => { 81 | const meeting = { date: '2025-08-10T07:00:00Z' }; 82 | 83 | const { container } = render( 84 | {}} 90 | />, 91 | ); 92 | 93 | const btn = screen.getByRole('button', { name: '07:00' }); 94 | expect(btn).toBeDisabled(); 95 | expect(container.querySelector('.meeting-selector__btn-loading')).toBeTruthy(); 96 | }); 97 | 98 | it('renders the empty state when meetingSlot.date is falsy', () => { 99 | const meeting = { date: '' }; 100 | 101 | render( 102 | {}} 108 | />, 109 | ); 110 | 111 | expect(screen.queryByRole('button')).not.toBeInTheDocument(); 112 | expect(screen.getByText('—')).toBeInTheDocument(); 113 | }); 114 | 115 | it('does NOT add selected class when a different meeting is selected (single object)', () => { 116 | const meetingRendered = { date: '2025-08-10T11:00:00Z' }; 117 | const selectedDifferent = [{ date: '2025-08-10T11:30:00Z' }]; 118 | 119 | const { container } = render( 120 | {}} 126 | />, 127 | ); 128 | 129 | const btn = screen.getByRole('button', { name: '11:00' }); 130 | expect(btn).toBeInTheDocument(); 131 | expect(container.querySelector('.meeting-selector__btn--selected')).toBeFalsy(); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /packages/common/src/helpers/meetingsByDayGenerator.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import { generateMeetingsByDays, generatePlaceHolder } from './meetingsByDayGenerator'; 3 | 4 | describe('meetingsByDayGenerator', () => { 5 | describe('generateMeetingsByDays', () => { 6 | it('should generate meetings by days correctly', () => { 7 | vi.useFakeTimers(); 8 | vi.setSystemTime(new Date('2023-05-01T07:00:00.000Z')); 9 | const startDate = new Date('2023-05-01T09:00:00.000Z'); 10 | const nbDays = 3; 11 | const interval = 30; 12 | const startTime = { hours: 9, minutes: 0 }; 13 | const endTime = { hours: 17, minutes: 0 }; 14 | 15 | const result = generateMeetingsByDays(startDate, nbDays, startTime, endTime, interval); 16 | 17 | expect(result).toBeDefined(); 18 | expect(result.length).toBe(3); 19 | expect(result[0].date.toISOString()).toBe('2023-05-01T09:00:00.000Z'); 20 | expect(result[0].slots.length).toBe(17); 21 | vi.useRealTimers(); 22 | }); 23 | 24 | it('should generate meetings by days correctly with day started', () => { 25 | vi.useFakeTimers(); 26 | vi.setSystemTime(new Date('2023-10-10T11:30:00.000Z')); 27 | const startDate = new Date('2023-10-10T09:00:00.000Z'); 28 | const nbDays = 3; 29 | const interval = 30; 30 | const startTime = { hours: 11, minutes: 0 }; 31 | const endTime = { hours: 17, minutes: 0 }; 32 | 33 | const result = generateMeetingsByDays(startDate, nbDays, startTime, endTime, interval); 34 | 35 | expect(result).toBeDefined(); 36 | expect(result.length).toBe(3); 37 | expect(result[0].date.toISOString()).toBe('2023-10-10T09:00:00.000Z'); 38 | expect(result[0].slots.length).toBe(12); 39 | vi.useRealTimers(); 40 | }); 41 | }); 42 | 43 | describe('generatePlaceHolder', () => { 44 | it('should generate a placeholder for meetings', () => { 45 | vi.useFakeTimers(); 46 | const date = new Date('2023-10-01T10:00:00.000Z'); 47 | vi.setSystemTime(date); 48 | const placeholder = generatePlaceHolder(date, 1); 49 | expect(placeholder).toEqual([ 50 | { 51 | date: new Date('2023-10-01T10:00:00.000Z'), 52 | slots: [], 53 | }, 54 | ]); 55 | vi.useRealTimers(); 56 | }); 57 | }); 58 | 59 | it('roundUpToInterval: keeps aligned times, rounds up when off-grid', () => { 60 | vi.useFakeTimers(); 61 | 62 | vi.setSystemTime(new Date('2023-10-10T11:30:00.000Z')); 63 | { 64 | const date = new Date('2023-10-10T00:00:00.000Z'); 65 | const startTime = { hours: 11, minutes: 0 }; 66 | const endTime = { hours: 12, minutes: 0 }; 67 | const interval = 30; 68 | 69 | const result = generateMeetingsByDays(date, 1, startTime, endTime, interval); 70 | expect(result[0].slots.length).toBe(2); 71 | expect(result[0].slots[0].date.toISOString()).toBe('2023-10-10T11:30:00.000Z'); 72 | } 73 | 74 | vi.setSystemTime(new Date('2023-10-10T11:31:00.000Z')); 75 | { 76 | const date = new Date('2023-10-10T00:00:00.000Z'); 77 | const startTime = { hours: 11, minutes: 0 }; 78 | const endTime = { hours: 12, minutes: 0 }; 79 | const interval = 30; 80 | 81 | const result = generateMeetingsByDays(date, 1, startTime, endTime, interval); 82 | expect(result[0].slots.length).toBe(1); 83 | expect(result[0].slots[0].date.toISOString()).toBe('2023-10-10T12:00:00.000Z'); 84 | } 85 | 86 | vi.useRealTimers(); 87 | }); 88 | 89 | it('first day: returns [] when start > dayEnd (now past end of day)', () => { 90 | vi.useFakeTimers(); 91 | vi.setSystemTime(new Date('2023-10-10T18:00:00.000Z')); 92 | 93 | const date = new Date('2023-10-10T00:00:00.000Z'); 94 | const startTime = { hours: 9, minutes: 0 }; 95 | const endTime = { hours: 17, minutes: 0 }; 96 | const interval = 30; 97 | 98 | const result = generateMeetingsByDays(date, 1, startTime, endTime, interval); 99 | expect(result[0].slots.length).toBe(0); 100 | 101 | vi.useRealTimers(); 102 | }); 103 | 104 | it('subsequent day: returns [] when start > end (inverted time window)', () => { 105 | vi.useFakeTimers(); 106 | vi.setSystemTime(new Date('2023-10-10T10:00:00.000Z')); 107 | 108 | const date = new Date('2023-10-10T00:00:00.000Z'); 109 | const nbDays = 2; 110 | const interval = 30; 111 | 112 | const startTime = { hours: 18, minutes: 0 }; 113 | const endTime = { hours: 17, minutes: 0 }; 114 | 115 | const result = generateMeetingsByDays(date, nbDays, startTime, endTime, interval); 116 | 117 | expect(result.length).toBe(2); 118 | expect(result[1].slots.length).toBe(0); // loop branch: start > end → [] 119 | vi.useRealTimers(); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /packages/common/src/assets/fonts/icons-font.svg: -------------------------------------------------------------------------------- 1 | Generated by Glyphter -------------------------------------------------------------------------------- /packages/vue-meeting-selector/README.md: -------------------------------------------------------------------------------- 1 | # vue-meeting-selector 2 | 3 | A fully-typed, accessible and customizable Vue component for displaying and selecting meeting slots grouped by day. Includes pagination, multi-selection, and render customization support. 4 | 5 | - [github](https://github.com/IneoO/meeting-selector) 6 | - [doc](https://meeting-selector.tuturu.io) 7 | 8 | ## Dependencies 9 | 10 | - vue: 3.x 11 | 12 | ## Installation 13 | 14 | ```bash 15 | # npm 16 | npm install vue-meeting-selector 17 | # pnpm 18 | pnpm add vue-meeting-selector 19 | # yarn 20 | yarn add vue-meeting-selector 21 | ``` 22 | 23 | ## Exemple 24 | 25 | ```html 26 | 41 | 42 | 115 | ``` 116 | 117 | ## Props 118 | 119 | | Prop | Type | Default | Required | Description | 120 | | ----------------- | -------------------------- | ------- | -------- | ---------------------------------------------------------- | 121 | | `meetingsByDays` | `MDay[]` | — | true | List of grouped meeting slots by day. | 122 | | `dateFieldKey` | `DateFieldKey` | — | true | The key used to extract the slot date (e.g., `'startAt'`). | 123 | | `meetingSlotsKey` | `MeetingSlotsKey` | — | true | The key used to extract the list of slots of the day. | 124 | | `date` | `Date` | — | true | The currently selected or reference date. | 125 | | `modelValue` | `MSlot \| MSlot[] \| null` | — | true | Selected slot(s), used with `v-model`. | 126 | | `multi` | `boolean` | `false` | false | Enables multiple slot selection. | 127 | | `calendarOptions` | `CalendarOptions` | `{}` | false | Configuration options for calendar display. | 128 | | `loading` | `boolean` | `false` | false | Whether the calendar is in a loading state. | 129 | | `skip` | `number` | `-1` | false | Number of slot rows to skip. Useful for pagination. | 130 | 131 | ## Utility Functions 132 | 133 | `generateMeetingsByDays(date, nbDays, startTime, endTime, interval)` 134 | Creates mock or real meeting slots grouped by day, spaced at regular intervals. 135 | 136 | ```typescript 137 | import { generateMeetingsByDays } from 'react-meeting-selector'; 138 | ``` 139 | 140 | [Full API Reference](https://meeting-selector.tuturu.io/common-meeting-selector/generate-meetings-by-days.html) 141 | 142 | `generatePlaceHolder(date, nbDays)` 143 | Generates an array of empty days with no slots — useful for loading states. 144 | 145 | ```typescript 146 | import { generatePlaceHolder } from 'react-meeting-selector'; 147 | ``` 148 | 149 | [Full API Reference](https://meeting-selector.tuturu.io/common-meeting-selector/generate-placeholder.html) 150 | -------------------------------------------------------------------------------- /docs/react-meeting-selector/props.md: -------------------------------------------------------------------------------- 1 | # Props 2 | 3 | This component displays meeting slots grouped by day. It is fully typed and supports advanced customization through props 4 | 5 | ## Props table 6 | 7 | | Prop | Type | Default | Required | Description | 8 | | -------------------------- | -------------------------------------------------------------------------------- | ------- | -------- | ------------------------------------------------------------------- | 9 | | `meetingsByDays` | `MDay[]` | — | true | List of grouped meeting slots by day. | 10 | | `dateFieldKey` | `DateFieldKey` | — | true | The key used to extract the slot date (e.g., `'date'`). | 11 | | `meetingSlotsKey` | `MeetingSlotsKey` | — | true | The key used to extract the list of slots of the day. | 12 | | `date` | `Date` | — | true | The currently selected or reference date. | 13 | | `value` | `MSlot \| MSlot[] \| null` | — | true | The currently selected slot(s). Controlled via `handleValueChange`. | 14 | | `handleValueChange` | `(val: MSlot \| null) => void` or `(val: MSlot[]) => void` | — | true | Callback invoked when the selection changes. Matches `value` type. | 15 | | `multi` | `boolean` | `false` | false | Whether multiple selections are allowed. | 16 | | `calendarOptions` | [`CalendarOptions`](#calendaroptions) | `{}` | false | Configuration options for calendar display. | 17 | | `loading` | `boolean` | `false` | false | Whether the calendar is in a loading state. | 18 | | `skip` | `number` | — | false | Number of slot rows to skip. Useful for pagination. | 19 | | `handleSkipChange` | `(skip: number) => void` | — | false | Callback to update skip manually (controlled pagination). | 20 | | `handlePreviousDate` | `() => void` | — | false | Callback triggered when going to the previous date range. | 21 | | `handleNextDate` | `() => void` | — | false | Callback triggered when going to the next date range. | 22 | | `renderButtonPreviousDate` | `(props: { loading: boolean; disabled: boolean }) => React.ReactNode` | — | false | Custom rendering for the "previous date" button. | 23 | | `renderButtonNextDate` | `(props: { loading: boolean; disabled: boolean }) => React.ReactNode` | — | false | Custom rendering for the "next date" button. | 24 | | `renderButtonUpMeetings` | `(props: { loading: boolean; disabled: boolean }) => React.ReactNode` | — | false | Custom rendering for the "previous page" (up) button. | 25 | | `renderButtonDownMeetings` | `(props: { loading: boolean; disabled: boolean }) => React.ReactNode` | — | false | Custom rendering for the "next page" (down) button. | 26 | | `renderHeader` | `(props: { meetings: MDay }) => React.ReactNode` | — | false | Custom rendering for each day's header. | 27 | | `renderMeeting` | `(props: { meeting: MSlot; index: number }) => React.ReactNode` | — | false | Custom rendering for individual meeting slots. | 28 | | `[...HTMLProps]` | All standard `HTMLDivElement` props (e.g. `id`, `className`, `data-*`, `aria-*`) | — | false | You can pass additional DOM attributes. | 29 | 30 | ## Types 31 | 32 | ### `MeetingSlot` 33 | 34 | ```typescript 35 | export type MeetingSlot = { 36 | [K in DateFieldKey]: string | Date; 37 | } & Record; 38 | ``` 39 | 40 | Represents a single meeting slot. It includes a dynamic date key (DateFieldKey, such as "startAt" or "date"), and any other custom data. 41 | 42 | ### `MeetingsByDay` 43 | 44 | ```typescript 45 | export type MeetingsByDay< 46 | DateFieldKey extends string, 47 | MeetingSlotsKey extends string, 48 | MSlot extends MeetingSlot 49 | > = { 50 | [K in DateFieldKey]: string | Date; 51 | } & { 52 | [K in MeetingSlotsKey]: MSlot[]; 53 | } & Record; 54 | ``` 55 | 56 | Represents a day grouping multiple slots, each of which follows the MeetingSlot shape. 57 | 58 | ### `CalendarOptions` 59 | 60 | ```typescript 61 | export type CalendarOptions = { 62 | daysLabel?: string[]; // Labels for days of the week 63 | monthsLabel?: string[]; // Labels for months 64 | limit?: number; // Max number of days to display 65 | spacing?: number; // Gap between days 66 | disabledDate?: (date: string | Date) => boolean; // Disable specific dates 67 | }; 68 | ``` 69 | 70 | Use this to control calendar appearance, layout, and behavior. 71 | -------------------------------------------------------------------------------- /devs/react-meeting-selector-dev/src/components/MultipleRendersElementsExample.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | MeetingSelector, 4 | generateMeetingsByDays, 5 | generatePlaceHolder, 6 | type MeetingsByDayGenerated, 7 | type MeetingSlotGenerated, 8 | type Time, 9 | } from 'react-meeting-selector'; 10 | import 'react-meeting-selector/style.css'; 11 | 12 | export const MultipleRendersElementsExample = () => { 13 | const nbDaysToDisplay = 5; 14 | 15 | const [date, setDate] = React.useState(new Date()); 16 | const initialDateRef = React.useRef(date); 17 | const [skip, setSkip] = React.useState(0); 18 | const [value, setValue] = React.useState(null); 19 | const [meetingsDays, setMeetingsDays] = React.useState( 20 | generatePlaceHolder(date, nbDaysToDisplay) 21 | ); 22 | const [loading, setLoading] = React.useState(true); 23 | 24 | const generateMeetingsByDaysAsync = React.useCallback( 25 | (d: Date, n: number, start: Time, end: Time, timesBetween: number) => 26 | new Promise((resolve) => { 27 | setTimeout(() => { 28 | resolve(generateMeetingsByDays(d, n, start, end, timesBetween)); 29 | }, 1000); 30 | }), 31 | [] 32 | ); 33 | 34 | const updateDays = React.useCallback( 35 | async (base: Date) => { 36 | setLoading(true); 37 | const start = { hours: 8, minutes: 0 }; 38 | const end = { hours: 16, minutes: 0 }; 39 | const generated = await generateMeetingsByDaysAsync(base, nbDaysToDisplay, start, end, 30); 40 | setMeetingsDays(generated); 41 | setLoading(false); 42 | }, 43 | [generateMeetingsByDaysAsync] 44 | ); 45 | 46 | const nextDate = React.useCallback(() => { 47 | const next = new Date(date); 48 | next.setDate(next.getDate() + 7); 49 | setDate(next); 50 | updateDays(next); 51 | }, [date, updateDays]); 52 | 53 | const previousDate = React.useCallback(() => { 54 | const prev = new Date(date); 55 | prev.setDate(prev.getDate() - 7); 56 | 57 | const now = new Date(); 58 | const effective = prev < now ? now : prev; 59 | 60 | setDate(effective); 61 | updateDays(effective); 62 | }, [date, updateDays]); 63 | 64 | const handleChange = React.useCallback((val: MeetingSlotGenerated | null) => { 65 | setValue(val); 66 | }, []); 67 | 68 | React.useEffect(() => { 69 | updateDays(initialDateRef.current); 70 | }, [updateDays]); 71 | 72 | const formattingDate = (d: Date | string) => { 73 | const date = new Date(d); 74 | const day = String(date.getDate()).padStart(2, '0'); 75 | const month = String(date.getMonth() + 1).padStart(2, '0'); 76 | return `${month}-${day}`; 77 | }; 78 | 79 | const formattingTime = (d: Date | string) => { 80 | const date = new Date(d); 81 | const hours = String(date.getHours()).padStart(2, '0'); 82 | const minutes = String(date.getMinutes()).padStart(2, '0'); 83 | return `${hours}:${minutes}`; 84 | }; 85 | 86 | const isPreviousDisabled = (() => { 87 | const prev = new Date(date); 88 | prev.setDate(prev.getDate() - 1); 89 | const now = new Date(); 90 | return formattingDate(prev) < formattingDate(now); 91 | })(); 92 | 93 | return ( 94 | <> 95 | 126 | 127 | value={value} 128 | date={date} 129 | skip={skip} 130 | loading={loading} 131 | dateFieldKey="date" 132 | meetingSlotsKey="slots" 133 | handleValueChange={handleChange} 134 | meetingsByDays={meetingsDays} 135 | handleNextDate={nextDate} 136 | handlePreviousDate={previousDate} 137 | handleSkipChange={setSkip} 138 | renderButtonPreviousDate={({ loading }) => ( 139 | 147 | )} 148 | renderButtonNextDate={({ loading }) => ( 149 | 152 | )} 153 | renderButtonUpMeetings={({ loading, disabled }) => ( 154 | 164 | )} 165 | renderButtonDownMeetings={({ loading, disabled }) => ( 166 | 176 | )} 177 | renderHeader={({ meetings }) => ( 178 |
{formattingDate(meetings.date)}
179 | )} 180 | renderMeeting={({ meeting, index }) => 181 | meeting.date ? ( 182 | 194 | ) : ( 195 |
196 | — 197 |
198 | ) 199 | } 200 | /> 201 |
Meeting selected: {JSON.stringify(value)}
202 | 203 | ); 204 | }; 205 | -------------------------------------------------------------------------------- /packages/vue-meeting-selector/src/components/meetingSelector/MeetingsDisplay.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeAll } from 'vitest'; 2 | import { render, screen, fireEvent } from '@testing-library/vue'; 3 | import MeetingsDisplay from './MeetingsDisplay.vue'; 4 | import type { MeetingsByDay, MeetingSlot } from 'common-meeting-selector'; 5 | 6 | beforeAll(() => { 7 | process.env.TZ = 'UTC'; 8 | }); 9 | 10 | type Slot = MeetingSlot<'date'>; 11 | type Day = MeetingsByDay<'date', 'slots', Slot>; 12 | type AnyDay = MeetingsByDay>; 13 | 14 | const makeDay = (slots: string[]): Day => ({ 15 | date: '2025-08-10', 16 | slots: slots.map((d) => ({ date: d })), 17 | }); 18 | 19 | describe('MeetingDisplay', () => { 20 | it('renders one MeetingSlotDisplay per slot with HH:mm labels', () => { 21 | const day = makeDay(['2025-08-10T12:00:00Z', '2025-08-10T13:30:00Z']); 22 | 23 | render(MeetingsDisplay, { 24 | props: { 25 | meetingsByDay: day as unknown as AnyDay, 26 | dateFieldKey: 'date', 27 | meetingSlotsKey: 'slots', 28 | loading: false, 29 | meetingSlotSelected: null, 30 | }, 31 | }); 32 | 33 | expect(screen.getByRole('button', { name: '12:00' })).toBeInTheDocument(); 34 | expect(screen.getByRole('button', { name: '13:30' })).toBeInTheDocument(); 35 | }); 36 | 37 | it('calls handleMeetingSlotClick with the clicked slot', async () => { 38 | const day = makeDay(['2025-08-10T08:05:00Z', '2025-08-10T09:00:00Z']); 39 | 40 | const { emitted } = render(MeetingsDisplay, { 41 | props: { 42 | meetingsByDay: day as unknown as AnyDay, 43 | dateFieldKey: 'date', 44 | meetingSlotsKey: 'slots', 45 | loading: false, 46 | meetingSlotSelected: null, 47 | }, 48 | }); 49 | 50 | await fireEvent.click(screen.getByRole('button', { name: '08:05' })); 51 | 52 | const ev = emitted()['meeting-slot-click']; 53 | expect(ev).toBeTruthy(); 54 | expect(ev!.length).toBe(1); 55 | expect(ev![0]).toEqual([{ date: '2025-08-10T08:05:00Z' }]); 56 | }); 57 | 58 | it('passes loading to children (buttons disabled + loading class present)', () => { 59 | const day = makeDay(['2025-08-10T07:00:00Z']); 60 | 61 | const { container } = render(MeetingsDisplay, { 62 | props: { 63 | meetingsByDay: day as unknown as AnyDay, 64 | dateFieldKey: 'date', 65 | meetingSlotsKey: 'slots', 66 | loading: true, 67 | meetingSlotSelected: null, 68 | }, 69 | }); 70 | 71 | const btn = screen.getByRole('button', { name: '07:00' }); 72 | expect(btn).toBeDisabled(); 73 | expect(container.querySelector('.meeting-selector__btn-loading')).toBeTruthy(); 74 | }); 75 | 76 | it('marks the matching slot as selected when meetingSlotSelected matches (single object)', () => { 77 | const day = makeDay(['2025-08-10T09:30:00Z', '2025-08-10T10:00:00Z']); 78 | 79 | const { container } = render(MeetingsDisplay, { 80 | props: { 81 | meetingsByDay: day as unknown as AnyDay, 82 | dateFieldKey: 'date', 83 | meetingSlotsKey: 'slots', 84 | loading: false, 85 | meetingSlotSelected: { date: '2025-08-10T10:00:00Z' }, 86 | }, 87 | }); 88 | 89 | expect(screen.getByRole('button', { name: '09:30' })).toBeInTheDocument(); 90 | expect(screen.getByRole('button', { name: '10:00' })).toBeInTheDocument(); 91 | const selected = container.querySelectorAll('.meeting-selector__btn--selected'); 92 | expect(selected.length).toBe(1); 93 | }); 94 | 95 | it('marks the matching slot as selected when meetingSlotSelected is an array', () => { 96 | const day = makeDay(['2025-08-10T09:00:00Z', '2025-08-10T10:00:00Z', '2025-08-10T11:00:00Z']); 97 | 98 | const { container } = render(MeetingsDisplay, { 99 | props: { 100 | meetingsByDay: day as unknown as AnyDay, 101 | dateFieldKey: 'date', 102 | meetingSlotsKey: 'slots', 103 | loading: false, 104 | meetingSlotSelected: [ 105 | { date: '2025-08-10T10:00:00Z' }, 106 | { date: '2025-08-10T12:00:00Z' }, // not displayed 107 | ], 108 | }, 109 | }); 110 | 111 | const selected = container.querySelectorAll('.meeting-selector__btn--selected'); 112 | expect(selected.length).toBe(1); 113 | }); 114 | 115 | // NEW: selected meeting exists, but it's NOT in the rendered list -> no button should be selected 116 | it('does NOT select anything when meetingSlotSelected is not present in the rendered slots (single object)', () => { 117 | const day = makeDay(['2025-08-10T11:00:00Z', '2025-08-10T11:30:00Z']); 118 | 119 | const { container } = render(MeetingsDisplay, { 120 | props: { 121 | meetingsByDay: day as unknown as AnyDay, 122 | dateFieldKey: 'date', 123 | meetingSlotsKey: 'slots', 124 | loading: false, 125 | meetingSlotSelected: { date: '2025-08-10T12:00:00Z' }, // not rendered 126 | }, 127 | }); 128 | 129 | expect(screen.getByRole('button', { name: '11:00' })).toBeInTheDocument(); 130 | expect(screen.getByRole('button', { name: '11:30' })).toBeInTheDocument(); 131 | expect(container.querySelector('.meeting-selector__btn--selected')).toBeFalsy(); 132 | }); 133 | 134 | it('does NOT select anything when meetingSlotSelected array does not include any rendered slot', () => { 135 | const day = makeDay(['2025-08-10T13:00:00Z', '2025-08-10T14:00:00Z']); 136 | 137 | const { container } = render(MeetingsDisplay, { 138 | props: { 139 | meetingsByDay: day as unknown as AnyDay, 140 | dateFieldKey: 'date', 141 | meetingSlotsKey: 'slots', 142 | loading: false, 143 | meetingSlotSelected: [{ date: '2025-08-10T12:00:00Z' }, { date: '2025-08-10T15:00:00Z' }], 144 | }, 145 | }); 146 | 147 | expect(container.querySelector('.meeting-selector__btn--selected')).toBeFalsy(); 148 | }); 149 | 150 | it('applies additional className on container', () => { 151 | const day = makeDay(['2025-08-10T12:00:00Z']); 152 | 153 | // Apply an extra class to the component root 154 | const { container } = render(MeetingsDisplay, { 155 | props: { 156 | meetingsByDay: day as unknown as AnyDay, 157 | dateFieldKey: 'date', 158 | meetingSlotsKey: 'slots', 159 | loading: false, 160 | meetingSlotSelected: null, 161 | }, 162 | attrs: { 163 | class: 'extra-class', 164 | }, 165 | }); 166 | 167 | const root = container.firstChild as HTMLElement; 168 | expect(root.className).toContain('meeting-selector__meetings'); 169 | expect(root.className).toContain('extra-class'); 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /packages/react-meeting-selector/src/components/meetingSelector/MeetingDisplay.spec.tsx: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeAll } from 'vitest'; 2 | import { render, screen, fireEvent } from '@testing-library/react'; 3 | import { MeetingDisplay } from './MeetingDisplay'; 4 | 5 | // Keep time formatting stable (component tree renders HH:mm via MeetingSlotDisplay) 6 | beforeAll(() => { 7 | process.env.TZ = 'UTC'; 8 | }); 9 | 10 | type Slot = { date: string }; 11 | type Day = { date: string; slots: Slot[] }; 12 | 13 | const makeDay = (slots: string[]): Day => ({ 14 | date: '2025-08-10', 15 | slots: slots.map((d) => ({ date: d })), 16 | }); 17 | 18 | describe('MeetingDisplay', () => { 19 | it('renders one MeetingSlotDisplay per slot with HH:mm labels', () => { 20 | const day = makeDay(['2025-08-10T12:00:00Z', '2025-08-10T13:30:00Z']); 21 | 22 | render( 23 | {}} 30 | />, 31 | ); 32 | 33 | expect(screen.getByRole('button', { name: '12:00' })).toBeInTheDocument(); 34 | expect(screen.getByRole('button', { name: '13:30' })).toBeInTheDocument(); 35 | }); 36 | 37 | it('calls handleMeetingSlotClick with the clicked slot', () => { 38 | const day = makeDay(['2025-08-10T08:05:00Z', '2025-08-10T09:00:00Z']); 39 | const onClick = vi.fn(); 40 | 41 | render( 42 | , 50 | ); 51 | 52 | fireEvent.click(screen.getByRole('button', { name: '08:05' })); 53 | expect(onClick).toHaveBeenCalledTimes(1); 54 | expect(onClick.mock.calls[0][0]).toEqual({ date: '2025-08-10T08:05:00Z' }); 55 | }); 56 | 57 | it('passes loading to children (buttons disabled + loading class present)', () => { 58 | const day = makeDay(['2025-08-10T07:00:00Z']); 59 | 60 | const { container } = render( 61 | {}} 68 | />, 69 | ); 70 | 71 | const btn = screen.getByRole('button', { name: '07:00' }); 72 | expect(btn).toBeDisabled(); 73 | expect(container.querySelector('.meeting-selector__btn-loading')).toBeTruthy(); 74 | }); 75 | 76 | it('marks the matching slot as selected when meetingSlotSelected matches (single object)', () => { 77 | const day = makeDay(['2025-08-10T09:30:00Z', '2025-08-10T10:00:00Z']); 78 | 79 | const { container } = render( 80 | {}} 87 | />, 88 | ); 89 | 90 | expect(screen.getByRole('button', { name: '09:30' })).toBeInTheDocument(); 91 | expect(screen.getByRole('button', { name: '10:00' })).toBeInTheDocument(); 92 | const selected = container.querySelectorAll('.meeting-selector__btn--selected'); 93 | expect(selected.length).toBe(1); 94 | }); 95 | 96 | it('marks the matching slot as selected when meetingSlotSelected is an array', () => { 97 | const day = makeDay(['2025-08-10T09:00:00Z', '2025-08-10T10:00:00Z', '2025-08-10T11:00:00Z']); 98 | 99 | const { container } = render( 100 | {}} 110 | />, 111 | ); 112 | 113 | const selected = container.querySelectorAll('.meeting-selector__btn--selected'); 114 | expect(selected.length).toBe(1); 115 | }); 116 | 117 | // NEW: selected meeting exists, but it's NOT in the rendered list -> no button should be selected 118 | it('does NOT select anything when meetingSlotSelected is not present in the rendered slots (single object)', () => { 119 | const day = makeDay(['2025-08-10T11:00:00Z', '2025-08-10T11:30:00Z']); 120 | 121 | const { container } = render( 122 | {}} 129 | />, 130 | ); 131 | 132 | expect(screen.getByRole('button', { name: '11:00' })).toBeInTheDocument(); 133 | expect(screen.getByRole('button', { name: '11:30' })).toBeInTheDocument(); 134 | expect(container.querySelector('.meeting-selector__btn--selected')).toBeFalsy(); 135 | }); 136 | 137 | it('does NOT select anything when meetingSlotSelected array does not include any rendered slot', () => { 138 | const day = makeDay(['2025-08-10T13:00:00Z', '2025-08-10T14:00:00Z']); 139 | 140 | const { container } = render( 141 | {}} 148 | />, 149 | ); 150 | 151 | expect(container.querySelector('.meeting-selector__btn--selected')).toBeFalsy(); 152 | }); 153 | 154 | it('uses renderMeeting when provided (and does not render default buttons)', () => { 155 | const day = makeDay(['2025-08-10T12:00:00Z', '2025-08-10T13:30:00Z']); 156 | const renderMeeting = vi.fn(({ meeting, index }: { meeting: Slot; index: number }) => ( 157 |
{meeting.date}
158 | )); 159 | 160 | render( 161 | {}} 168 | renderMeeting={renderMeeting} 169 | />, 170 | ); 171 | 172 | expect(renderMeeting).toHaveBeenCalledTimes(2); 173 | expect(screen.getByTestId('custom-0')).toBeInTheDocument(); 174 | expect(screen.getByTestId('custom-1')).toBeInTheDocument(); 175 | // Default buttons should not be there if custom rendering is used 176 | expect(screen.queryByRole('button')).toBeNull(); 177 | }); 178 | 179 | it('applies additional className on container', () => { 180 | const day = makeDay(['2025-08-10T12:00:00Z']); 181 | 182 | const { container } = render( 183 | {}} 191 | />, 192 | ); 193 | 194 | const root = container.firstChild as HTMLElement; 195 | expect(root.className).toContain('meeting-selector__meetings'); 196 | expect(root.className).toContain('extra-class'); 197 | }); 198 | }); 199 | -------------------------------------------------------------------------------- /devs/vue-meeting-selector-dev/src/components/SlotsExample.vue: -------------------------------------------------------------------------------- 1 | 68 | 69 | 225 | 226 | 253 | -------------------------------------------------------------------------------- /packages/react-meeting-selector/README.md: -------------------------------------------------------------------------------- 1 | # react-meeting-selector 2 | 3 | A fully-typed, accessible and customizable React component for displaying and selecting meeting slots grouped by day. Includes pagination, multi-selection, and render customization support. 4 | 5 | - [github](https://github.com/IneoO/meeting-selector) 6 | - [doc](https://meeting-selector.tuturu.io) 7 | 8 | ## Dependencies 9 | 10 | - React: 19.x 11 | 12 | ## Installation 13 | 14 | ```bash 15 | # npm 16 | npm install react-meeting-selector 17 | # pnpm 18 | pnpm add react-meeting-selector 19 | # yarn 20 | yarn add react-meeting-selector 21 | ``` 22 | 23 | ## Exemple 24 | 25 | ```typescript 26 | import React, { useState, useCallback, useEffect, useRef } from 'react'; 27 | import { 28 | MeetingSelector, 29 | generateMeetingsByDays, 30 | type MeetingsByDayGenerated, 31 | type MeetingSlotGenerated, 32 | type Time, 33 | } from 'react-meeting-selector'; 34 | import 'react-meeting-selector/style.css'; 35 | 36 | export const SimpleExample = () => { 37 | const [date, setDate] = useState(new Date()); 38 | const initialDateRef = useRef(date); 39 | const [skip, setSkip] = useState(0); 40 | const [value, setValue] = useState(null); 41 | const [meetingsDays, setMeetingsDays] = useState([]); 42 | const nbDaysToDisplay = 5; 43 | 44 | const handleChange = useCallback((val: MeetingSlotGenerated | null) => { 45 | setValue(val); 46 | }, []); 47 | 48 | const nextDate = useCallback(() => { 49 | const newDate = new Date(date); 50 | newDate.setDate(newDate.getDate() + 7); 51 | setDate(newDate); 52 | setMeetingsDays(generateMeetingsByDays(newDate, nbDaysToDisplay, { hours: 8, minutes: 0 }, { hours: 16, minutes: 0 }, 30)); 53 | }, [date]); 54 | 55 | const previousDate = useCallback(() => { 56 | const newDate = new Date(date); 57 | newDate.setDate(newDate.getDate() - 7); 58 | setDate(newDate); 59 | setMeetingsDays(generateMeetingsByDays(newDate, nbDaysToDisplay, { hours: 8, minutes: 0 }, { hours: 16, minutes: 0 }, 30)); 60 | }, [date]); 61 | 62 | useEffect(() => { 63 | setMeetingsDays(generateMeetingsByDays(initialDateRef.current, nbDaysToDisplay, { hours: 8, minutes: 0 }, { hours: 16, minutes: 0 }, 30)); 64 | }, []); 65 | 66 | return ( 67 | <> 68 | 80 | meetingSlot: {JSON.stringify(value) ?? ''} 81 | 82 | ); 83 | }; 84 | ``` 85 | 86 | ## Props 87 | 88 | | Prop | Type | Default | Required | Description | 89 | | -------------------------- | -------------------------------------------------------------------------------- | ------- | -------- | ------------------------------------------------------------------- | 90 | | `meetingsByDays` | `MDay[]` | — | true | List of grouped meeting slots by day. | 91 | | `dateFieldKey` | `DateFieldKey` | — | true | The key used to extract the slot date (e.g., `'date'`). | 92 | | `meetingSlotsKey` | `MeetingSlotsKey` | — | true | The key used to extract the list of slots of the day. | 93 | | `date` | `Date` | — | true | The currently selected or reference date. | 94 | | `value` | `MSlot \| MSlot[] \| null` | — | true | The currently selected slot(s). Controlled via `handleValueChange`. | 95 | | `handleValueChange` | `(val: MSlot \| null) => void` or `(val: MSlot[]) => void` | — | true | Callback invoked when the selection changes. Matches `value` type. | 96 | | `multi` | `boolean` | `false` | false | Enables multiple slot selection. | 97 | | `calendarOptions` | `CalendarOptions` | `{}` | false | Configuration options for calendar display. | 98 | | `loading` | `boolean` | `false` | false | Whether the calendar is in a loading state. | 99 | | `skip` | `number` | — | false | Number of slot rows to skip. Useful for pagination. | 100 | | `handleSkipChange` | `(skip: number) => void` | — | false | Callback to update skip manually (controlled pagination). | 101 | | `handlePreviousDate` | `() => void` | — | false | Callback triggered when going to the previous date range. | 102 | | `handleNextDate` | `() => void` | — | false | Callback triggered when going to the next date range. | 103 | | `renderButtonPreviousDate` | `(props: { loading: boolean; disabled: boolean }) => React.ReactNode` | — | false | Custom rendering for the "previous date" button. | 104 | | `renderButtonNextDate` | `(props: { loading: boolean; disabled: boolean }) => React.ReactNode` | — | false | Custom rendering for the "next date" button. | 105 | | `renderButtonUpMeetings` | `(props: { loading: boolean; disabled: boolean }) => React.ReactNode` | — | false | Custom rendering for the "previous page" (up) button. | 106 | | `renderButtonDownMeetings` | `(props: { loading: boolean; disabled: boolean }) => React.ReactNode` | — | false | Custom rendering for the "next page" (down) button. | 107 | | `renderHeader` | `(props: { meetings: MDay }) => React.ReactNode` | — | false | Custom rendering for each day's header. | 108 | | `renderMeeting` | `(props: { meeting: MSlot; index: number }) => React.ReactNode` | — | false | Custom rendering for individual meeting slots. | 109 | | `[...HTMLProps]` | All standard `HTMLDivElement` props (e.g. `id`, `className`, `data-*`, `aria-*`) | — | false | Additional DOM attributes for the wrapper element. | 110 | 111 | ## Utility Functions 112 | 113 | `generateMeetingsByDays(date, nbDays, startTime, endTime, interval)` 114 | Creates mock or real meeting slots grouped by day, spaced at regular intervals. 115 | 116 | ```typescript 117 | import { generateMeetingsByDays } from 'react-meeting-selector'; 118 | ``` 119 | 120 | [Full API Reference](https://meeting-selector.tuturu.io/common-meeting-selector/generate-meetings-by-days.html) 121 | 122 | `generatePlaceHolder(date, nbDays)` 123 | Generates an array of empty days with no slots — useful for loading states. 124 | 125 | ```typescript 126 | import { generatePlaceHolder } from 'react-meeting-selector'; 127 | ``` 128 | 129 | [Full API Reference](https://meeting-selector.tuturu.io/common-meeting-selector/generate-placeholder.html) 130 | -------------------------------------------------------------------------------- /packages/vue-meeting-selector/src/components/meetingSelector/MeetingSelector.vue: -------------------------------------------------------------------------------- 1 | 88 | 89 | 273 | -------------------------------------------------------------------------------- /packages/react-meeting-selector/src/components/meetingSelector/MeetingSelector.tsx: -------------------------------------------------------------------------------- 1 | import { type HTMLAttributes, useEffect, useMemo, useState, useCallback } from 'react'; 2 | import 'common-meeting-selector/style'; 3 | import 'common-meeting-selector/icons'; 4 | import { 5 | defaultCalendarOptions, 6 | type CalendarOptions, 7 | type MeetingSlot, 8 | type MeetingsByDay, 9 | } from 'common-meeting-selector'; 10 | import { ArrowIcon } from './ArrowIcon'; 11 | import { MeetingDisplay } from './MeetingDisplay'; 12 | import { DayDisplay } from './DayDisplay'; 13 | import { memo } from 'react'; 14 | 15 | type renderButtonProps = { 16 | loading: boolean; 17 | disabled: boolean; 18 | }; 19 | 20 | type BaseProps< 21 | DateFieldKey extends string, 22 | MeetingSlotsKey extends string, 23 | MSlot extends MeetingSlot, 24 | MDay extends MeetingsByDay, 25 | > = { 26 | meetingsByDays: MDay[]; 27 | dateFieldKey: DateFieldKey; 28 | meetingSlotsKey: MeetingSlotsKey; 29 | date: Date; 30 | calendarOptions?: CalendarOptions; 31 | loading?: boolean; 32 | skip?: number; 33 | renderButtonPreviousDate?: (props: renderButtonProps) => React.ReactNode; 34 | renderButtonNextDate?: (props: renderButtonProps) => React.ReactNode; 35 | renderButtonUpMeetings?: (props: renderButtonProps) => React.ReactNode; 36 | renderButtonDownMeetings?: (props: renderButtonProps) => React.ReactNode; 37 | renderHeader?: (props: { meetings: MDay }) => React.ReactNode; 38 | renderMeeting?: (props: { meeting: MSlot; index: number }) => React.ReactNode; 39 | handlePreviousDate?: () => void; 40 | handleNextDate?: () => void; 41 | handleSkipChange?: (skip: number) => void; 42 | } & HTMLAttributes; 43 | 44 | export type MeetingSelectorProps< 45 | DateFieldKey extends string, 46 | MeetingSlotsKey extends string, 47 | MSlot extends MeetingSlot, 48 | MDay extends MeetingsByDay, 49 | > = 50 | | (BaseProps & { 51 | multi?: false; 52 | value: MSlot | null; 53 | handleValueChange: (val: MSlot | null) => void; 54 | }) 55 | | (BaseProps & { 56 | multi: true; 57 | value: MSlot[]; 58 | handleValueChange: (val: MSlot[]) => void; 59 | }); 60 | 61 | export const MeetingSelectorComponent = < 62 | DateFieldKey extends string, 63 | MeetingSlotsKey extends string, 64 | MSlot extends MeetingSlot, 65 | MDay extends MeetingsByDay, 66 | >({ 67 | meetingsByDays, 68 | dateFieldKey, 69 | meetingSlotsKey, 70 | date, 71 | calendarOptions = {}, 72 | loading = false, 73 | skip, 74 | renderButtonPreviousDate, 75 | renderButtonNextDate, 76 | renderButtonUpMeetings, 77 | renderButtonDownMeetings, 78 | renderHeader, 79 | renderMeeting, 80 | handlePreviousDate, 81 | handleNextDate, 82 | handleSkipChange, 83 | className, 84 | ...props 85 | }: MeetingSelectorProps) => { 86 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 87 | const { handleValueChange: _h, value: _v, multi: _m, ...htmlProps } = props; 88 | 89 | const options = useMemo( 90 | () => ({ ...defaultCalendarOptions, ...calendarOptions }), 91 | [calendarOptions], 92 | ); 93 | 94 | const isControlled = typeof skip === 'number'; 95 | const [internalSkip, setInternalSkip] = useState(isControlled ? skip : 0); 96 | 97 | const setSkip = (newSkip: number) => { 98 | if (isControlled) { 99 | handleSkipChange?.(newSkip); 100 | } else { 101 | setInternalSkip(newSkip); 102 | } 103 | }; 104 | 105 | useEffect(() => { 106 | if (isControlled) { 107 | setInternalSkip(skip); 108 | } 109 | }, [skip, isControlled]); 110 | 111 | const maxNbMeetings = useMemo(() => { 112 | if (!meetingsByDays.length) { 113 | return 0; 114 | } 115 | return Math.max( 116 | ...meetingsByDays.map((meetingsByDay) => meetingsByDay[meetingSlotsKey].length), 117 | ); 118 | }, [meetingsByDays, meetingSlotsKey]); 119 | 120 | const meetingsByDaysPaginated = useMemo(() => { 121 | const arrayIndex = Math.ceil(maxNbMeetings / options.limit) * options.limit; 122 | return meetingsByDays.map((meetingsByDay) => { 123 | const slots: MSlot[] = Array.from( 124 | { length: arrayIndex }, 125 | () => ({ [dateFieldKey]: '' }) as MSlot, 126 | ); 127 | slots.splice( 128 | 0, 129 | meetingsByDay[meetingSlotsKey].length, 130 | ...(meetingsByDay[meetingSlotsKey] as MSlot[]), 131 | ); 132 | const day = { 133 | ...meetingsByDay, 134 | slots: slots.slice(internalSkip, internalSkip + options.limit), 135 | }; 136 | return day; 137 | }); 138 | }, [maxNbMeetings, internalSkip, options.limit, dateFieldKey, meetingsByDays, meetingSlotsKey]); 139 | 140 | const handlePreviousMeetingsClick = () => { 141 | setSkip(internalSkip - options.spacing); 142 | }; 143 | const handleNextMeetingsClick = () => { 144 | setSkip(internalSkip + options.spacing); 145 | }; 146 | 147 | const handleMeetingClick = useCallback( 148 | (meeting: MSlot) => { 149 | const multi = props.multi === true; 150 | const value = props.value; 151 | const handle = props.handleValueChange; 152 | 153 | if (multi) { 154 | const v = value as MSlot[]; 155 | const h = handle as (val: MSlot[]) => void; 156 | 157 | const timestamp = new Date(meeting[dateFieldKey]).getTime(); 158 | const existingIndex = v.findIndex((s) => new Date(s[dateFieldKey]).getTime() === timestamp); 159 | const next = [...v]; 160 | if (existingIndex !== -1) { 161 | next.splice(existingIndex, 1); 162 | h(next); 163 | } else { 164 | next.push(meeting); 165 | h(next); 166 | } 167 | } else { 168 | const v = value as MSlot | null; 169 | const h = handle as (val: MSlot | null) => void; 170 | 171 | if (v) { 172 | const selected = new Date(meeting[dateFieldKey]).getTime(); 173 | const current = new Date(v[dateFieldKey]).getTime(); 174 | if (selected === current) { 175 | h(null); 176 | return; 177 | } 178 | } 179 | h(meeting); 180 | } 181 | }, 182 | [props.multi, props.value, props.handleValueChange, dateFieldKey], 183 | ); 184 | 185 | return ( 186 |
187 |
188 |
189 | {renderButtonPreviousDate?.({ loading, disabled: options.disabledDate(date) }) ?? ( 190 | 198 | )} 199 |
200 |
201 | {meetingsByDaysPaginated.map((meetingsByDay) => ( 202 |
203 | {renderHeader?.({ meetings: meetingsByDay }) ?? ( 204 | 212 | )} 213 | renderMeeting({ meeting, index }) 223 | : undefined 224 | } 225 | /> 226 |
227 | ))} 228 |
229 |
230 | {renderButtonNextDate?.({ loading, disabled: false }) ?? ( 231 | 239 | )} 240 | {renderButtonUpMeetings?.({ disabled: internalSkip === 0, loading }) ?? ( 241 | 250 | )} 251 | {renderButtonDownMeetings?.({ 252 | disabled: internalSkip + options.limit >= maxNbMeetings, 253 | loading, 254 | }) ?? ( 255 | 264 | )} 265 |
266 |
267 |
268 | ); 269 | }; 270 | 271 | export const MeetingSelector = memo(MeetingSelectorComponent) as typeof MeetingSelectorComponent; 272 | --------------------------------------------------------------------------------