├── 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 |
2 |
3 |
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 |
2 |
3 |
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 |
2 |
3 |
Simple example
4 |
5 | Simple async example
6 |
7 | Multi example
8 |
9 | Slots example
10 |
11 |
12 |
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 | 
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 |
2 |
3 |
4 | {{ title }}
5 |
6 |
7 | {{ subtitle }}
8 |
9 |
10 |
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 |
29 |
30 |
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 |
2 |
3 |
4 |
11 |
12 |
13 |
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 |
2 |
3 |
13 |
—
14 |
15 |
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 |
2 |
3 |
13 | Meeting selected: {{ meeting }}
14 |
15 |
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 |
2 |
3 |
14 | Meeting selected: {{ meeting }}
15 |
16 |
17 |
18 |
115 |
116 |
134 |
--------------------------------------------------------------------------------
/devs/vue-meeting-selector-dev/src/components/MultiExample.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
15 | Meeting selected: {{ meetings }}
16 |
17 |
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 |
--------------------------------------------------------------------------------
/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 |
27 |
28 |
38 | Meeting selected: {{ meeting }}
39 |
40 |
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 |
2 |
3 |
15 |
16 | {{ formattingDate(meetings.date) }}
17 |
18 |
19 |
25 | {{ formattingTime(meeting.date) }}
26 |
27 | —
28 |
29 |
30 |
38 |
39 |
40 |
43 |
44 |
45 |
53 |
54 |
55 |
63 |
64 |
65 | Meeting selected: {{ meeting }}
66 |
67 |
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 |
2 |
3 |
4 |
17 |
18 |
23 |
24 |
32 |
33 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
85 |
86 |
87 |
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 |
--------------------------------------------------------------------------------