initial commit
This commit is contained in:
commit
c7bac1a7a0
4
.git-blame-ignore-revs
Normal file
4
.git-blame-ignore-revs
Normal file
@ -0,0 +1,4 @@
|
||||
# Ran prettier on source
|
||||
# after adding tailwind plugin
|
||||
54563d9deba9d95c1212c1be2217ce8fa181162b
|
||||
8e07a190aff0eba2a3e072f9857a654dca6fa0e1
|
||||
18
.github/workflows/publish.yml
vendored
Normal file
18
.github/workflows/publish.yml
vendored
Normal file
@ -0,0 +1,18 @@
|
||||
name: Publish on NPM
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 18
|
||||
- run: npm install
|
||||
- run: npm test
|
||||
- uses: JS-DevTools/npm-publish@v1
|
||||
with:
|
||||
token: ${{ secrets.NPM_TOKEN }}
|
||||
38
.github/workflows/story-publish.yml
vendored
Normal file
38
.github/workflows/story-publish.yml
vendored
Normal file
@ -0,0 +1,38 @@
|
||||
name: Build and Deploy Storybook
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
|
||||
- name: Build package
|
||||
run: yarn run build
|
||||
|
||||
- name: Build Histoire
|
||||
run: yarn run story:build
|
||||
|
||||
- name: Create CNAME file
|
||||
run: echo 'ui.jingrow.com' > ./.histoire/dist/CNAME
|
||||
|
||||
- name: Deploy Histoire to
|
||||
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
github_token: ${{ secrets.JINGROW_UI_PAGES_TOKEN }}
|
||||
publish_dir: ./.histoire/dist
|
||||
publish_branch: gh-pages
|
||||
25
.github/workflows/vitest.yml
vendored
Normal file
25
.github/workflows/vitest.yml
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Run prettier check and tests
|
||||
run: yarn test
|
||||
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
1
.husky/.gitignore
vendored
Normal file
1
.husky/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
_
|
||||
6
.postcssrc.js
Normal file
6
.postcssrc.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
27
.pre-commit-config.yaml
Normal file
27
.pre-commit-config.yaml
Normal file
@ -0,0 +1,27 @@
|
||||
exclude: 'node_modules|.git'
|
||||
default_stages: [commit]
|
||||
fail_fast: false
|
||||
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: v4.0.0-alpha.8
|
||||
hooks:
|
||||
- id: prettier
|
||||
types_or:
|
||||
- javascript
|
||||
- vue
|
||||
additional_dependencies:
|
||||
- prettier
|
||||
- prettier-plugin-tailwindcss
|
||||
exclude: |
|
||||
(?x)^(
|
||||
.*node_modules.*|
|
||||
.*boilerplate.*|
|
||||
.*src.*.js|
|
||||
)$
|
||||
|
||||
ci:
|
||||
autoupdate_schedule: weekly
|
||||
skip: []
|
||||
submodules: false
|
||||
6
.prettierrc.json
Normal file
6
.prettierrc.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"proseWrap": "always"
|
||||
}
|
||||
14
404.html
Normal file
14
404.html
Normal file
@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="refresh" content="0; url=./index.html">
|
||||
<script>
|
||||
// JavaScript redirect as a fallback
|
||||
const path = window.location.pathname;
|
||||
window.location.href = `/?route=${path}`;
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>
|
||||
7
App.vue
Normal file
7
App.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<script setup>
|
||||
import { Badge } from './src'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Badge> Gamma </Badge>
|
||||
</template>
|
||||
10
auto-imports.d.ts
vendored
Normal file
10
auto-imports.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
|
||||
}
|
||||
192
components.d.ts
vendored
Normal file
192
components.d.ts
vendored
Normal file
@ -0,0 +1,192 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
Alert: typeof import('./src/components/Alert/Alert.vue')['default']
|
||||
AlignCenter: typeof import('./src/components/TextEditor/icons/align-center.vue')['default']
|
||||
AlignJustify: typeof import('./src/components/TextEditor/icons/align-justify.vue')['default']
|
||||
AlignLeft: typeof import('./src/components/TextEditor/icons/align-left.vue')['default']
|
||||
AlignRight: typeof import('./src/components/TextEditor/icons/align-right.vue')['default']
|
||||
ArrowGoBackLine: typeof import('./src/components/TextEditor/icons/arrow-go-back-line.vue')['default']
|
||||
ArrowGoForwardLine: typeof import('./src/components/TextEditor/icons/arrow-go-forward-line.vue')['default']
|
||||
Autocomplete: typeof import('./src/components/Autocomplete/Autocomplete.vue')['default']
|
||||
'Autocomplete.story': typeof import('./src/components/Autocomplete/Autocomplete.story.vue')['default']
|
||||
Avatar: typeof import('./src/components/Avatar/Avatar.vue')['default']
|
||||
'Avatar.story': typeof import('./src/components/Avatar/Avatar.story.vue')['default']
|
||||
AxisChart: typeof import('./src/components/Charts/AxisChart.vue')['default']
|
||||
Badge: typeof import('./src/components/Badge/Badge.vue')['default']
|
||||
'Badge.story': typeof import('./src/components/Badge/Badge.story.vue')['default']
|
||||
Bold: typeof import('./src/components/TextEditor/icons/bold.vue')['default']
|
||||
Breadcrumbs: typeof import('./src/components/Breadcrumbs/Breadcrumbs.vue')['default']
|
||||
'Breadcrumbs.story': typeof import('./src/components/Breadcrumbs/Breadcrumbs.story.vue')['default']
|
||||
Button: typeof import('./src/components/Button/Button.vue')['default']
|
||||
'Button.story': typeof import('./src/components/Button/Button.story.vue')['default']
|
||||
Calendar: typeof import('./src/components/Calendar/Calendar.vue')['default']
|
||||
'Calendar.story': typeof import('./src/components/Calendar/Calendar.story.vue')['default']
|
||||
CalendarDaily: typeof import('./src/components/Calendar/CalendarDaily.vue')['default']
|
||||
CalendarEvent: typeof import('./src/components/Calendar/CalendarEvent.vue')['default']
|
||||
CalendarMonthly: typeof import('./src/components/Calendar/CalendarMonthly.vue')['default']
|
||||
CalendarTimeMarker: typeof import('./src/components/Calendar/CalendarTimeMarker.vue')['default']
|
||||
CalendarWeekly: typeof import('./src/components/Calendar/CalendarWeekly.vue')['default']
|
||||
Card: typeof import('./src/components/Card.vue')['default']
|
||||
'Charts.story': typeof import('./src/components/Charts/Charts.story.vue')['default']
|
||||
Checkbox: typeof import('./src/components/Checkbox/Checkbox.vue')['default']
|
||||
'Checkbox.story': typeof import('./src/components/Checkbox/Checkbox.story.vue')['default']
|
||||
CircularProgressBar: typeof import('./src/components/CircularProgressBar/CircularProgressBar.vue')['default']
|
||||
'CircularProgressBar.story': typeof import('./src/components/CircularProgressBar/CircularProgressBar.story.vue')['default']
|
||||
CodeBlockComponent: typeof import('./src/components/TextEditor/CodeBlockComponent.vue')['default']
|
||||
CodeView: typeof import('./src/components/TextEditor/icons/code-view.vue')['default']
|
||||
Combobox: typeof import('./src/components/Combobox/Combobox.vue')['default']
|
||||
'Combobox.story': typeof import('./src/components/Combobox/Combobox.story.vue')['default']
|
||||
CommandPalette: typeof import('./src/components/CommandPalette/CommandPalette.vue')['default']
|
||||
CommandPaletteItem: typeof import('./src/components/CommandPalette/CommandPaletteItem.vue')['default']
|
||||
ConfirmDialog: typeof import('./src/components/ConfirmDialog.vue')['default']
|
||||
DateMonthYearPicker: typeof import('./src/components/Calendar/DateMonthYearPicker.vue')['default']
|
||||
DatePicker: typeof import('./src/components/DatePicker/DatePicker.vue')['default']
|
||||
'DatePicker.story': typeof import('./src/components/DatePicker/DatePicker.story.vue')['default']
|
||||
DateRangePicker: typeof import('./src/components/DatePicker/DateRangePicker.vue')['default']
|
||||
DateTimePicker: typeof import('./src/components/DatePicker/DateTimePicker.vue')['default']
|
||||
DayIcon: typeof import('./src/components/Calendar/Icon/DayIcon.vue')['default']
|
||||
Dialog: typeof import('./src/components/Dialog/Dialog.vue')['default']
|
||||
'Dialog.story': typeof import('./src/components/Dialog/Dialog.story.vue')['default']
|
||||
Dialogs: typeof import('./src/components/Dialogs.vue')['default']
|
||||
Divider: typeof import('./src/components/Divider/Divider.vue')['default']
|
||||
DonutChart: typeof import('./src/components/Charts/DonutChart.vue')['default']
|
||||
DoubleQuotesR: typeof import('./src/components/TextEditor/icons/double-quotes-r.vue')['default']
|
||||
Dropdown: typeof import('./src/components/Dropdown/Dropdown.vue')['default']
|
||||
'Dropdown.story': typeof import('./src/components/Dropdown/Dropdown.story.vue')['default']
|
||||
ECharts: typeof import('./src/components/Charts/ECharts.vue')['default']
|
||||
EditLink: typeof import('./src/components/TextEditor/EditLink.vue')['default']
|
||||
EmojiList: typeof import('./src/components/TextEditor/extensions/emoji/EmojiList.vue')['default']
|
||||
ErrorMessage: typeof import('./src/components/ErrorMessage/ErrorMessage.vue')['default']
|
||||
'ErrorMessage.story': typeof import('./src/components/ErrorMessage/ErrorMessage.story.vue')['default']
|
||||
EventModalContent: typeof import('./src/components/Calendar/EventModalContent.vue')['default']
|
||||
FeatherIcon: typeof import('./src/components/FeatherIcon.vue')['default']
|
||||
FileUploader: typeof import('./src/components/FileUploader/FileUploader.vue')['default']
|
||||
'FileUploader.story': typeof import('./src/components/FileUploader/FileUploader.story.vue')['default']
|
||||
FilterIcon: typeof import('./src/components/ListFilter/FilterIcon.vue')['default']
|
||||
FloatingPopover: typeof import('./src/components/Calendar/FloatingPopover.vue')['default']
|
||||
FontColor: typeof import('./src/components/TextEditor/FontColor.vue')['default']
|
||||
FormatClear: typeof import('./src/components/TextEditor/icons/format-clear.vue')['default']
|
||||
FormControl: typeof import('./src/components/FormControl/FormControl.vue')['default']
|
||||
'FormControl.story': typeof import('./src/components/FormControl/FormControl.story.vue')['default']
|
||||
FormLabel: typeof import('./src/components/FormLabel.vue')['default']
|
||||
JingrowUIProvider: typeof import('./src/components/Provider/JingrowUIProvider.vue')['default']
|
||||
FunnelChart: typeof import('./src/components/Charts/FunnelChart.vue')['default']
|
||||
GreenCheckIcon: typeof import('./src/components/GreenCheckIcon.vue')['default']
|
||||
H1: typeof import('./src/components/TextEditor/icons/h-1.vue')['default']
|
||||
H2: typeof import('./src/components/TextEditor/icons/h-2.vue')['default']
|
||||
H3: typeof import('./src/components/TextEditor/icons/h-3.vue')['default']
|
||||
H4: typeof import('./src/components/TextEditor/icons/h-4.vue')['default']
|
||||
H5: typeof import('./src/components/TextEditor/icons/h-5.vue')['default']
|
||||
H6: typeof import('./src/components/TextEditor/icons/h-6.vue')['default']
|
||||
IframeNodeView: typeof import('./src/components/TextEditor/extensions/iframe/IframeNodeView.vue')['default']
|
||||
ImageAddLine: typeof import('./src/components/TextEditor/icons/image-add-line.vue')['default']
|
||||
ImageGroupNodeView: typeof import('./src/components/TextEditor/extensions/image-group/ImageGroupNodeView.vue')['default']
|
||||
ImageGroupUploadDialog: typeof import('./src/components/TextEditor/extensions/image-group/ImageGroupUploadDialog.vue')['default']
|
||||
ImageNodeView: typeof import('./src/components/TextEditor/extensions/image/ImageNodeView.vue')['default']
|
||||
ImageViewerModal: typeof import('./src/components/TextEditor/ImageViewerModal.vue')['default']
|
||||
Input: typeof import('./src/components/Input.vue')['default']
|
||||
InsertIframe: typeof import('./src/components/TextEditor/extensions/iframe/InsertIframe.vue')['default']
|
||||
InsertImage: typeof import('./src/components/TextEditor/InsertImage.vue')['default']
|
||||
InsertLink: typeof import('./src/components/TextEditor/InsertLink.vue')['default']
|
||||
InsertVideo: typeof import('./src/components/TextEditor/InsertVideo.vue')['default']
|
||||
Italic: typeof import('./src/components/TextEditor/icons/italic.vue')['default']
|
||||
KeyboardShortcut: typeof import('./src/components/KeyboardShortcut.vue')['default']
|
||||
Layout: typeof import('./src/components/VueGridLayout/Layout.vue')['default']
|
||||
Link: typeof import('./src/components/Link.vue')['default']
|
||||
ListEmptyState: typeof import('./src/components/ListView/ListEmptyState.vue')['default']
|
||||
ListFilter: typeof import('./src/components/ListFilter/ListFilter.vue')['default']
|
||||
ListFooter: typeof import('./src/components/ListView/ListFooter.vue')['default']
|
||||
ListGroupHeader: typeof import('./src/components/ListView/ListGroupHeader.vue')['default']
|
||||
ListGroupRows: typeof import('./src/components/ListView/ListGroupRows.vue')['default']
|
||||
ListGroups: typeof import('./src/components/ListView/ListGroups.vue')['default']
|
||||
ListHeader: typeof import('./src/components/ListView/ListHeader.vue')['default']
|
||||
ListHeaderItem: typeof import('./src/components/ListView/ListHeaderItem.vue')['default']
|
||||
ListItem: typeof import('./src/components/ListItem.vue')['default']
|
||||
ListOrdered: typeof import('./src/components/TextEditor/icons/list-ordered.vue')['default']
|
||||
ListRow: typeof import('./src/components/ListView/ListRow.vue')['default']
|
||||
ListRowItem: typeof import('./src/components/ListView/ListRowItem.vue')['default']
|
||||
ListRows: typeof import('./src/components/ListView/ListRows.vue')['default']
|
||||
ListSelectBanner: typeof import('./src/components/ListView/ListSelectBanner.vue')['default']
|
||||
ListTask: typeof import('./src/components/TextEditor/icons/list-task.vue')['default']
|
||||
ListUnordered: typeof import('./src/components/TextEditor/icons/list-unordered.vue')['default']
|
||||
ListView: typeof import('./src/components/ListView/ListView.vue')['default']
|
||||
'ListView.story': typeof import('./src/components/ListView/ListView.story.vue')['default']
|
||||
LoadingIndicator: typeof import('./src/components/LoadingIndicator.vue')['default']
|
||||
LoadingText: typeof import('./src/components/LoadingText.vue')['default']
|
||||
LucideCalendar: typeof import('~icons/lucide/calendar')['default']
|
||||
LucideCheck: typeof import('~icons/lucide/check')['default']
|
||||
LucideChevronDown: typeof import('~icons/lucide/chevron-down')['default']
|
||||
LucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
|
||||
LucideX: typeof import('~icons/lucide/x')['default']
|
||||
MentionList: typeof import('./src/components/TextEditor/MentionList.vue')['default']
|
||||
Menu: typeof import('./src/components/TextEditor/Menu.vue')['default']
|
||||
MonthIcon: typeof import('./src/components/Calendar/Icon/MonthIcon.vue')['default']
|
||||
NestedPopover: typeof import('./src/components/ListFilter/NestedPopover.vue')['default']
|
||||
NewEventModal: typeof import('./src/components/Calendar/NewEventModal.vue')['default']
|
||||
NumberChart: typeof import('./src/components/Charts/NumberChart.vue')['default']
|
||||
Password: typeof import('./src/components/Password/Password.vue')['default']
|
||||
'Password.story': typeof import('./src/components/Password/Password.story.vue')['default']
|
||||
Popover: typeof import('./src/components/Popover/Popover.vue')['default']
|
||||
'Popover.story': typeof import('./src/components/Popover/Popover.story.vue')['default']
|
||||
Progress: typeof import('./src/components/Progress/Progress.vue')['default']
|
||||
'Progress.story': typeof import('./src/components/Progress/Progress.story.vue')['default']
|
||||
Rating: typeof import('./src/components/Rating/Rating.vue')['default']
|
||||
'Rating.story': typeof import('./src/components/Rating/Rating.story.vue')['default']
|
||||
Resource: typeof import('./src/components/Resource.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SearchComplete: typeof import('./src/components/ListFilter/SearchComplete.vue')['default']
|
||||
Select: typeof import('./src/components/Select/Select.vue')['default']
|
||||
'Select.story': typeof import('./src/components/Select/Select.story.vue')['default']
|
||||
Separator: typeof import('./src/components/TextEditor/icons/separator.vue')['default']
|
||||
ShowMoreCalendarEvent: typeof import('./src/components/Calendar/ShowMoreCalendarEvent.vue')['default']
|
||||
Sidebar: typeof import('./src/components/Sidebar/Sidebar.vue')['default']
|
||||
'Sidebar.story': typeof import('./src/components/Sidebar/Sidebar.story.vue')['default']
|
||||
SidebarHeader: typeof import('./src/components/Sidebar/SidebarHeader.vue')['default']
|
||||
SidebarItem: typeof import('./src/components/Sidebar/SidebarItem.vue')['default']
|
||||
SidebarSection: typeof import('./src/components/Sidebar/SidebarSection.vue')['default']
|
||||
SlashCommandsList: typeof import('./src/components/TextEditor/extensions/slash-commands/SlashCommandsList.vue')['default']
|
||||
Spinner: typeof import('./src/components/Spinner/Spinner.vue')['default']
|
||||
'Spinner.story': typeof import('./src/components/Spinner/Spinner.story.vue')['default']
|
||||
Strikethrough: typeof import('./src/components/TextEditor/icons/strikethrough.vue')['default']
|
||||
SuggestionList: typeof import('./src/components/TextEditor/extensions/suggestion/SuggestionList.vue')['default']
|
||||
Switch: typeof import('./src/components/Switch/Switch.vue')['default']
|
||||
'Switch.story': typeof import('./src/components/Switch/Switch.story.vue')['default']
|
||||
TabButtons: typeof import('./src/components/TabButtons/TabButtons.vue')['default']
|
||||
'TabButtons.story': typeof import('./src/components/TabButtons/TabButtons.story.vue')['default']
|
||||
Table2: typeof import('./src/components/TextEditor/icons/table-2.vue')['default']
|
||||
TabList: typeof import('./src/components/Tabs/TabList.vue')['default']
|
||||
TabPanel: typeof import('./src/components/Tabs/TabPanel.vue')['default']
|
||||
Tabs: typeof import('./src/components/Tabs/Tabs.vue')['default']
|
||||
'Tabs.story': typeof import('./src/components/Tabs/Tabs.story.vue')['default']
|
||||
Text: typeof import('./src/components/TextEditor/icons/text.vue')['default']
|
||||
Textarea: typeof import('./src/components/Textarea/Textarea.vue')['default']
|
||||
'Textarea.story': typeof import('./src/components/Textarea/Textarea.story.vue')['default']
|
||||
TextEditor: typeof import('./src/components/TextEditor/TextEditor.vue')['default']
|
||||
'TextEditor.story': typeof import('./src/components/TextEditor/TextEditor.story.vue')['default']
|
||||
TextEditorBubbleMenu: typeof import('./src/components/TextEditor/TextEditorBubbleMenu.vue')['default']
|
||||
TextEditorFixedMenu: typeof import('./src/components/TextEditor/TextEditorFixedMenu.vue')['default']
|
||||
TextEditorFloatingMenu: typeof import('./src/components/TextEditor/TextEditorFloatingMenu.vue')['default']
|
||||
TextInput: typeof import('./src/components/TextInput/TextInput.vue')['default']
|
||||
'TextInput.story': typeof import('./src/components/TextInput/TextInput.story.vue')['default']
|
||||
TimePicker: typeof import('./src/components/TimePicker/TimePicker.vue')['default']
|
||||
'TimePicker.story': typeof import('./src/components/TimePicker/TimePicker.story.vue')['default']
|
||||
Toast: typeof import('./src/components/Toast/Toast.vue')['default']
|
||||
ToastProvider: typeof import('./src/components/Toast/ToastProvider.vue')['default']
|
||||
Tooltip: typeof import('./src/components/Tooltip/Tooltip.vue')['default']
|
||||
'Tooltip.story': typeof import('./src/components/Tooltip/Tooltip.story.vue')['default']
|
||||
Tree: typeof import('./src/components/Tree/Tree.vue')['default']
|
||||
'Tree.story': typeof import('./src/components/Tree/Tree.story.vue')['default']
|
||||
Underline: typeof import('./src/components/TextEditor/icons/underline.vue')['default']
|
||||
VideoAddLine: typeof import('./src/components/TextEditor/icons/video-add-line.vue')['default']
|
||||
WeekIcon: typeof import('./src/components/Calendar/Icon/WeekIcon.vue')['default']
|
||||
}
|
||||
}
|
||||
57
docs/Getting Started.story.md
Normal file
57
docs/Getting Started.story.md
Normal file
@ -0,0 +1,57 @@
|
||||
# Getting Started
|
||||
|
||||
This page will help you with setting up `jingrow-ui` in a new project as well as
|
||||
an existing Jingrow project.
|
||||
|
||||
## Quick start
|
||||
|
||||
You can quickly setup `jingrow-ui` using
|
||||
[`jingrow-ui-starter`](https://github.com/netchampfaris/jingrow-ui-starter). If
|
||||
you already have a Jingrow app for which you want to build a frontend you can
|
||||
start with **Step 2**.
|
||||
|
||||
### 1. Create your Jingrow app
|
||||
|
||||
```sh
|
||||
bench new-app todo
|
||||
```
|
||||
|
||||
### 2. Setup jingrow-ui
|
||||
|
||||
```sh
|
||||
cd apps/todo
|
||||
# this will setup a vue project with jingrow-ui set up
|
||||
# inside the frontend directory
|
||||
npx degit netchampfaris/jingrow-ui-starter frontend
|
||||
```
|
||||
|
||||
Refer [jingrow-ui-starter](https://github.com/netchampfaris/jingrow-ui-starter)
|
||||
for more details.
|
||||
|
||||
### 3. ignore_csrf config
|
||||
|
||||
```sh
|
||||
bench --site todo.test set-config ignore_csrf 1
|
||||
```
|
||||
|
||||
This will prevent CSRFToken errors while using the vite dev server. In
|
||||
production environment, the csrf_token is attached to the window object in
|
||||
index.html for you.
|
||||
|
||||
### 4. Start dev server
|
||||
|
||||
```sh
|
||||
cd frontend
|
||||
yarn
|
||||
yarn dev
|
||||
```
|
||||
|
||||
The Vite dev server will start on the port `8080`. This can be changed from
|
||||
`vite.config.js`. The development server is configured to proxy your jingrow app
|
||||
(usually running on port 8000). If you have a site named `todo.test`, open
|
||||
`http://todo.test:8080` in your browser. If you see a button named "Click to
|
||||
send 'ping' request", congratulations!
|
||||
|
||||
If you notice the browser URL is `/frontend`, this is the base URL where your
|
||||
frontend app will run in production. To change this, open `src/router.js` and
|
||||
change the base URL passed to `createWebHistory`.
|
||||
81
docs/Introduction.story.md
Normal file
81
docs/Introduction.story.md
Normal file
@ -0,0 +1,81 @@
|
||||
# What is Jingrow UI?
|
||||
|
||||
Jingrow UI is a set of components and utilities to build frontend apps based on
|
||||
the [Jingrow Framework](https://framework.jingrow.com).
|
||||
|
||||
Along with generic components which are required to build a frontend like
|
||||
Button, Link, Dialog, etc., jingrow-ui also contains utilities for handling
|
||||
server-side data fetching, directives and utilities.
|
||||
|
||||
**Usage example**
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { Button, LoadingText, createResource } from 'jingrow-ui'
|
||||
|
||||
let todos = createResource({
|
||||
type: 'list',
|
||||
pagetype: 'ToDo',
|
||||
fields: ['name', 'description'],
|
||||
cache: 'ToDos',
|
||||
auto: true,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LoadingText v-if="todos.loading" />
|
||||
<ul v-else>
|
||||
<li v-for="todo in todos.data" :key="todo.name">
|
||||
{{ todo.description }}
|
||||
</li>
|
||||
</ul>
|
||||
<Button>Add ToDo</Button>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
Jingrow UI is built on top of the following amazing projects –
|
||||
|
||||
- [Vue 3](https://vuejs.org)
|
||||
- [TailwindCSS](https://tailwindcss.com)
|
||||
- [Headless UI](https://headlessui.com)
|
||||
- [PopperJS](https://popper.js.org/)
|
||||
- [TipTap](https://tiptap.dev)
|
||||
- [Feather Icons](https://feathericons.com)
|
||||
|
||||
See full list of dependencies:
|
||||
[package.json](https://github.com/jingrow/jingrow-ui/blob/main/package.json)
|
||||
|
||||
## Motivation
|
||||
|
||||
In 2019, I started building [Jingrow Books](https://jingrowbooks.com) based on an
|
||||
experimental design system by [Timeless](https://timeless.co). As the product
|
||||
got built, a set of small reusable components (like Button, Dialog, Card, etc.)
|
||||
were also built.
|
||||
|
||||
After the launch of Jingrow Books (and me dropping the project) I moved on to
|
||||
building the UI for [Jingrow Cloud](https://jcloud.jingrow.com) in 2020. It also
|
||||
needed these components, so I copy-pasted them from Jingrow Books to Jingrow
|
||||
Cloud. These components evolved over time in Jingrow Cloud. After working on the
|
||||
Jingrow Cloud UI for about a year and a half, I moved on to my next project.
|
||||
|
||||
At the start of 2022, I started working on
|
||||
[Gameplan](https://github.com/jingrow/gameplan). I didn't want to copy-paste yet
|
||||
again, so I extracted these components in a separate package called
|
||||
[`jingrow-ui`](https://npm.im/jingrow-ui). This package is being developed in
|
||||
parallel along with the Gameplan project. I keep adding generic components and
|
||||
utilities useful for frontend development.
|
||||
|
||||
## Products
|
||||
|
||||
Jingrow UI is now being used in a lot of products by Jingrow.
|
||||
|
||||
- [Jingrow Cloud](https://jcloud.jingrow.com)
|
||||
- [Gameplan](https://github.com/jingrow/gameplan)
|
||||
- [Jingrow Insights](https://github.com/jingrow/insights)
|
||||
- [Jingrow Drive](https://github.com/jingrow/drive)
|
||||
|
||||
## License
|
||||
|
||||
Jingrow UI is MIT licensed
|
||||
BIN
docs/assets/dialog-slots.png
Normal file
BIN
docs/assets/dialog-slots.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
45
docs/components/alert.md
Normal file
45
docs/components/alert.md
Normal file
@ -0,0 +1,45 @@
|
||||
<script setup>
|
||||
import { Alert, Button } from '../../src/index'
|
||||
</script>
|
||||
|
||||
# Alert
|
||||
|
||||
## Usage
|
||||
|
||||
<Story class="gap-4" :iframe="true">
|
||||
<Alert title="Info">
|
||||
<span>
|
||||
This is a test alert message
|
||||
</span>
|
||||
<template #actions>
|
||||
<Button appearance="primary">Take Action</Button>
|
||||
</template>
|
||||
</Alert>
|
||||
</Story>
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Alert title="Info">
|
||||
<span> This is a test alert message </span>
|
||||
<template #actions>
|
||||
<Button appearance="primary">Take Action</Button>
|
||||
</template>
|
||||
</Alert>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Alert, Button } from 'jingrow-ui'
|
||||
</script>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Name | Default | Value |
|
||||
| :------ | :------ | :------- |
|
||||
| `title` | `null` | `String` |
|
||||
|
||||
## Slots
|
||||
|
||||
| Name | Description |
|
||||
| :-------- | :--------------------------------- |
|
||||
| `default` | Default slot to render the message |
|
||||
64
docs/components/autocomplete.md
Normal file
64
docs/components/autocomplete.md
Normal file
@ -0,0 +1,64 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { Autocomplete } from '../../src/index'
|
||||
let fruit = ref(null)
|
||||
</script>
|
||||
|
||||
# Autocomplete
|
||||
|
||||
The Autocomplete component is used to select an option from a list of options.
|
||||
Additionally, it provides a search input to filter the options.
|
||||
|
||||
## Usage
|
||||
|
||||
<Story>
|
||||
<div class="w-1/2">
|
||||
<Autocomplete
|
||||
:options="[
|
||||
{label: 'Apple', value: 'apple'},
|
||||
{label: 'Banana', value: 'banana'},
|
||||
{label: 'Orange', value: 'orange'},
|
||||
]"
|
||||
v-model="fruit"
|
||||
placeholder="Select a fruit"
|
||||
/>
|
||||
<div class="text-base mt-4">Selected Value:</div>
|
||||
<pre class="text-base">{{ JSON.stringify(fruit) }}</pre>
|
||||
</div>
|
||||
</Story>
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Autocomplete
|
||||
:options="[
|
||||
{ label: 'Apple', value: 'apple' },
|
||||
{ label: 'Banana', value: 'banana' },
|
||||
{ label: 'Orange', value: 'orange' },
|
||||
]"
|
||||
v-model="fruit"
|
||||
placeholder="Select a fruit"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref } from 'vue'
|
||||
import { Autocomplete } from 'jingrow-ui'
|
||||
let fruit = ref(null)
|
||||
</script>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Name | Default | Value | Description |
|
||||
| :------------ | :------ | :------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `modelValue` | `null` | `Object` | No need to directly pass this prop. Just use `v-model`. |
|
||||
| `value` | `null` | `Object` | This prop should be used if you are not using `v-model`. Value must be one of the options object from `options` or `null`. This object must be a direct reference and not a copy. |
|
||||
| `options` | `null` | `Array` | Array of objects with `label` and `value` keys |
|
||||
| `placeholder` | `null` | `String` | String to show as placeholder when no value is set |
|
||||
|
||||
## Events
|
||||
|
||||
| Name | Description |
|
||||
| :------------------ | :----------------------------------------------------------------------------- |
|
||||
| `update:modelValue` | This event is emitted when value is changed or unset and `v-model` is used. |
|
||||
| `change` | This event is emitted when value is changed or unset and `value` prop is used. |
|
||||
55
docs/components/avatar.md
Normal file
55
docs/components/avatar.md
Normal file
@ -0,0 +1,55 @@
|
||||
<script setup>
|
||||
import { Avatar } from '../../src/index'
|
||||
</script>
|
||||
|
||||
# Avatar
|
||||
|
||||
The Avatar component is usually used to show display picture of a user or a
|
||||
fallback with their initials.
|
||||
|
||||
## Usage
|
||||
|
||||
<Story class="gap-4">
|
||||
|
||||
<Avatar imageURL="https://placekitten.com/100" label="Whiskers" />
|
||||
<Avatar imageURL="" label="Whiskers" />
|
||||
|
||||
<Avatar imageURL="https://placekitten.com/150" label="Charlie" size="sm" />
|
||||
<Avatar imageURL="" label="Charlie" size="sm" />
|
||||
|
||||
<Avatar imageURL="https://placekitten.com/200" label="Felix" size="lg" />
|
||||
<Avatar imageURL="" label="Felix" size="lg" />
|
||||
|
||||
<Avatar imageURL="https://placekitten.com/180" label="Snowy" shape="square" />
|
||||
<Avatar imageURL="" label="Snowy" shape="square" />
|
||||
|
||||
</Story>
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Avatar imageURL="https://placekitten.com/100" label="Whiskers" />
|
||||
<Avatar imageURL="" label="Whiskers" />
|
||||
|
||||
<Avatar imageURL="https://placekitten.com/150" label="Charlie" size="sm" />
|
||||
<Avatar imageURL="" label="Charlie" size="sm" />
|
||||
|
||||
<Avatar imageURL="https://placekitten.com/200" label="Felix" size="lg" />
|
||||
<Avatar imageURL="" label="Felix" size="lg" />
|
||||
|
||||
<Avatar imageURL="https://placekitten.com/180" label="Snowy" shape="square" />
|
||||
<Avatar imageURL="" label="Snowy" shape="square" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Avatar } from 'jingrow-ui'
|
||||
</script>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Name | Default | Value | Description |
|
||||
| :--------- | :------- | :----------------- | :--------------------------------------------------------------------------------- |
|
||||
| `imageURL` | `null` | `String` | URL to an image |
|
||||
| `label` | `null` | `String` | First character of the label will be used as a placeholder when imageURL is `null` |
|
||||
| `size` | `'md'` | `sm \| md \| lg` | Size of the element |
|
||||
| `shape` | `circle` | `circle \| square` | Shape of the element |
|
||||
56
docs/components/badge.md
Normal file
56
docs/components/badge.md
Normal file
@ -0,0 +1,56 @@
|
||||
<script setup>
|
||||
import {Badge} from '../../src/index'
|
||||
let colorMap = {
|
||||
'Success': 'green',
|
||||
'Warning': 'yellow',
|
||||
'Info': 'blue',
|
||||
}
|
||||
</script>
|
||||
|
||||
# Badge
|
||||
|
||||
The Badge component is used to show a status badge for an entity.
|
||||
|
||||
## Usage
|
||||
|
||||
<Story class="gap-4">
|
||||
<Badge>Draft</Badge>
|
||||
<Badge color="yellow">Pending</Badge>
|
||||
<Badge color="green">Completed</Badge>
|
||||
<Badge color="red">Error</Badge>
|
||||
<Badge color="blue">In Progress</Badge>
|
||||
<Badge :colorMap="colorMap" label="Success" />
|
||||
<Badge :colorMap="colorMap" label="Warning" />
|
||||
<Badge :colorMap="colorMap" label="Info" />
|
||||
</Story>
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Badge>Draft</Badge>
|
||||
<Badge color="yellow">Pending</Badge>
|
||||
<Badge color="green">Completed</Badge>
|
||||
<Badge color="red">Error</Badge>
|
||||
<Badge color="blue">In Progress</Badge>
|
||||
<!-- using colorMap and label -->
|
||||
<Badge :colorMap="colorMap" label="Success" />
|
||||
<Badge :colorMap="colorMap" label="Warning" />
|
||||
<Badge :colorMap="colorMap" label="Info" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Badge } from 'jingrow-ui'
|
||||
let colorMap = {
|
||||
Success: 'green',
|
||||
Warning: 'yellow',
|
||||
Info: 'blue',
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Name | Default | Value | Description |
|
||||
| :--------- | :------- | :--------------------------------------- | :--------------------------------------------------- |
|
||||
| `color` | `'gray'` | `gray \| yellow \| green \| red \| blue` | |
|
||||
| `colorMap` | `null` | `Object` | An object containing label as key and color as value |
|
||||
| `label` | `null` | `String` | This must be passed when using `colorMap` |
|
||||
60
docs/components/button.md
Normal file
60
docs/components/button.md
Normal file
@ -0,0 +1,60 @@
|
||||
<script setup>
|
||||
import Button from '../../src/components/Button.vue'
|
||||
const alert = (text) => window.alert(text)
|
||||
</script>
|
||||
|
||||
# Button
|
||||
|
||||
The Button component is used to trigger an action such as submitting a form,
|
||||
opening a Dialog, or canceling an action.
|
||||
|
||||
## Usage
|
||||
|
||||
<Story class="gap-4">
|
||||
<Button @click="alert('Hello')">Default</Button>
|
||||
<Button appearance="primary">Primary</Button>
|
||||
<Button appearance="danger">Danger</Button>
|
||||
<Button appearance="minimal">Minimal</Button>
|
||||
<Button icon="x" />
|
||||
<Button icon-left="menu">Menu</Button>
|
||||
<Button icon-right="external-link">Link</Button>
|
||||
<Button :loading="true">Submit</Button>
|
||||
</Story>
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Button @click="alert('Hello')">Default</Button>
|
||||
<Button appearance="primary">Primary</Button>
|
||||
<Button appearance="danger">Danger</Button>
|
||||
<Button appearance="minimal">Minimal</Button>
|
||||
<Button icon="x" />
|
||||
<Button icon-left="menu">Menu</Button>
|
||||
<Button icon-right="external-link">Link</Button>
|
||||
<Button :loading="true">Submit</Button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Button } from 'jingrow-ui'
|
||||
</script>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Name | Default | Value | Description |
|
||||
| :------------ | :------------ | :------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------- |
|
||||
| `label` | `null` | `String` | |
|
||||
| `appearance` | `'secondary'` | `primary \| secondary \| danger \| success \| warning \| white \| minimal` | |
|
||||
| `disabled` | `false` | `true \| false` | |
|
||||
| `active` | `false` | `true \| false` | Only applicable if `appearance` is `minimal` |
|
||||
| `icon` | `null` | [Feather Icon](/components/feathericon) name | Will only display icon without label. If `label` is provided, `aria-label` will be set to that value. |
|
||||
| `iconLeft` | `null` | [Feather Icon](/components/feathericon) name | |
|
||||
| `iconRight` | `null` | [Feather Icon](/components/feathericon) name | |
|
||||
| `loading` | `false` | `true \| false` | Will show a loading spinner to the left of the button text |
|
||||
| `loadingText` | `null` | `String` | Set this to change the button text in `loading` state |
|
||||
| `route` | `null` | `String \| Object` | If you are using `vue-router`, you can pass a valid `route` value and click handler will be added |
|
||||
| `link` | `null` | `String` | URL to open in a new window on button click |
|
||||
|
||||
## Events
|
||||
|
||||
All attributes and event listeners are passed down to the underlying `button`
|
||||
element, so `@click` and other events will just work like on a normal button.
|
||||
64
docs/components/confirm-dialog.md
Normal file
64
docs/components/confirm-dialog.md
Normal file
@ -0,0 +1,64 @@
|
||||
# Confirm Dialog
|
||||
|
||||
This component is to confirm an action with the user.
|
||||
|
||||
## Usage
|
||||
|
||||
Call the `confirmDialog` function with options to show a confirmation dialog.
|
||||
You need to make sure you include the `Dialogs` component in your root component
|
||||
([`App.vue`](#app-vue)).
|
||||
|
||||
<Story>
|
||||
<Button
|
||||
@click="
|
||||
confirmDialog({
|
||||
title: 'Are you sure?',
|
||||
message: 'This will permanently delete the file. Are you sure you want to proceed?',
|
||||
onConfirm: ({ hideDialog }) => {
|
||||
// deleteFile()
|
||||
// hideDialog() // closes dialog
|
||||
},
|
||||
onCancel: () => {}
|
||||
})
|
||||
"
|
||||
>
|
||||
Delete File
|
||||
</Button>
|
||||
</Story>
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Button
|
||||
@click="
|
||||
confirmDialog({
|
||||
title: 'Are you sure?',
|
||||
message:
|
||||
'This will permanently delete the file. Are you sure you want to proceed?',
|
||||
onConfirm: ({ hideDialog }) => {
|
||||
// deleteFile()
|
||||
// hideDialog() // closes dialog
|
||||
},
|
||||
onCancel: () => {}
|
||||
})
|
||||
"
|
||||
>
|
||||
Delete File
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { confirmDialog, Button } from 'jingrow-ui'
|
||||
</script>
|
||||
```
|
||||
|
||||
### App.vue
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- your markup -->
|
||||
<Dialogs />
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialogs } from 'jingrow-ui'
|
||||
</script>
|
||||
```
|
||||
48
docs/components/datepicker.md
Normal file
48
docs/components/datepicker.md
Normal file
@ -0,0 +1,48 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { DatePicker } from '../../src/index'
|
||||
let date = ref(null)
|
||||
</script>
|
||||
|
||||
# DatePicker
|
||||
|
||||
The DatePicker component is a prettier alternative to `<input type="date">`
|
||||
|
||||
## Usage
|
||||
|
||||
<Story class="gap-4">
|
||||
<div class="space-y-4">
|
||||
<DatePicker v-model="date" />
|
||||
<DatePicker
|
||||
v-model="date"
|
||||
placeholder="Select your birthday"
|
||||
:formatValue="(val) => val.split('-').reverse().join('-')"
|
||||
/>
|
||||
<pre class="text-base">Value: {{ date }}</pre>
|
||||
</div>
|
||||
</Story>
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<DatePicker v-model="date" />
|
||||
|
||||
<DatePicker
|
||||
v-model="date"
|
||||
placeholder="Select your birthday"
|
||||
:formatValue="(val) => val.split('-').reverse().join('-')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { DatePicker } from 'jingrow-ui'
|
||||
let date = ref(null)
|
||||
</script>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Name | Default | Value | Description |
|
||||
| :------------ | :------ | :--------- | :-------------------------------------------- |
|
||||
| `placeholder` | `null` | `String` | |
|
||||
| `formatValue` | `null` | `Function` | Function to format the date value for display |
|
||||
214
docs/components/dialog.md
Normal file
214
docs/components/dialog.md
Normal file
@ -0,0 +1,214 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { Dialog, Button } from '../../src/index'
|
||||
let optionsDialog = ref(false)
|
||||
let slotsDialog = ref(false)
|
||||
let bodyDialog = ref(false)
|
||||
let log = console.log
|
||||
let asyncJob = () => new Promise(resolve => setTimeout(resolve, 1000))
|
||||
</script>
|
||||
|
||||
# Dialog
|
||||
|
||||
The Dialog is a component that is rendered over the page with an overlay. It is
|
||||
used to show messages or actions when user's attention is needed.
|
||||
|
||||
## Usage (props)
|
||||
|
||||
The `options` prop allows you set a number of options for a standard Dialog
|
||||
layout.
|
||||
|
||||
<Story>
|
||||
<Button @click="optionsDialog = true">Show Dialog</Button>
|
||||
<Dialog
|
||||
v-model="optionsDialog"
|
||||
:options="{
|
||||
title: 'Update Role',
|
||||
message: 'Are you sure want to update this role?',
|
||||
// icon: {
|
||||
// name: 'alert-triangle',
|
||||
// appearance: 'warning',
|
||||
// },
|
||||
// size: 'sm',
|
||||
actions: [
|
||||
{
|
||||
label: 'Confirm',
|
||||
appearance: 'primary',
|
||||
handler: ({ close }) => {
|
||||
// do action
|
||||
close() //
|
||||
},
|
||||
},
|
||||
// actions without a handler will automatically close the dialog
|
||||
{ label: 'Cancel' },
|
||||
],
|
||||
}"
|
||||
/>
|
||||
</Story>
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Button @click="showDialog = true">Show Dialog</Button>
|
||||
<Dialog
|
||||
:options="{
|
||||
title: 'Update Role',
|
||||
message: 'Are you sure want to update this role?',
|
||||
icon: {
|
||||
name: 'alert-triangle',
|
||||
appearance: 'warning',
|
||||
},
|
||||
size: 'sm',
|
||||
actions: [
|
||||
{
|
||||
label: 'Confirm',
|
||||
appearance: 'primary',
|
||||
handler: ({ close }) => {
|
||||
// updateRole()
|
||||
close() // closes dialog
|
||||
},
|
||||
},
|
||||
{ label: 'Cancel' },
|
||||
],
|
||||
}"
|
||||
v-model="showDialog"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { Dialog, Button } from 'jingrow-ui'
|
||||
let showDialog = ref(false)
|
||||
</script>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
There is only one prop `option` which is an Object. Each of the properties of
|
||||
this object is described in the table below.
|
||||
|
||||
| Name | Default | Value | Description |
|
||||
| :------------------------ | :----------------- | :--------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `options.title` | `Untitled` | `String` | Title of the dialog |
|
||||
| `options.message` | `null` | `String` | Message shown in the body of the dialog |
|
||||
| `options.icon` | `String \| Object` | [FeatherIcon](./feathericon) name or `Object{name, appearance}` | Icon shown to the left of title |
|
||||
| `options.icon.name` | `String` | [FeatherIcon](./feathericon) name | |
|
||||
| `options.icon.appearance` | `null` | `warning \| info \| danger \| success` | Changes the color of icon |
|
||||
| `options.size` | `'lg'` | `xs \| sm \| md \| lg \| xl \| 2xl \| 3xl \| 4xl \| 5xl \| 6xl \| 7xl` | Change the width of the dialog |
|
||||
| `options.actions` | `null` | Array of [Button](./button) props | Each object in the array must be an object of props passed to a Button component. Click handler is set via `handler` property. |
|
||||
|
||||
## Usage (slots)
|
||||
|
||||
If you want to take control over the markup of each part of the dialog, you can
|
||||
use slots. The Dialog components is made up of nested slots which allows you to
|
||||
override a part in a granular way.
|
||||
|
||||

|
||||
|
||||
Here are the slot names corresponding to the marked region in the above
|
||||
screenshot:
|
||||
|
||||
| Number | Name | Description |
|
||||
| :----- | :------------- | :----------------------------------- |
|
||||
| 1 | `body` | Override the full body of the dialog |
|
||||
| 2 | `body-main` | Override the main content |
|
||||
| 3 | `actions` | Override the actions |
|
||||
| 4 | `body-title` | Override the title |
|
||||
| 5 | `body-content` | Override the message |
|
||||
|
||||
In the following example, the slots `body-title` and `body-content` are used.
|
||||
Notice that we can still use `options.actions` to show our action buttons.
|
||||
|
||||
<Story>
|
||||
<Button @click="slotsDialog = true">Show Dialog</Button>
|
||||
<Dialog
|
||||
:options="{
|
||||
actions: [
|
||||
{ label: 'Destroy', appearance: 'danger' },
|
||||
{ label: 'Cancel' },
|
||||
],
|
||||
}"
|
||||
v-model="slotsDialog"
|
||||
>
|
||||
<template #body-title>
|
||||
<h2 class="text-3xl font-bold">My custom title</h2>
|
||||
</template>
|
||||
<template #body-content>
|
||||
<p class="mt-4 text-lg">
|
||||
Irure Lorem culpa nulla fugiat nulla labore aliquip exercitation laboris
|
||||
ex qui. Aliquip pariatur esse amet laboris. Veniam ullamco dolore
|
||||
incididunt consequat commodo id consectetur labore et. Eiusmod esse
|
||||
laborum irure aliquip laboris sunt dolore voluptate sint ea mollit do
|
||||
Lorem. Sit aute dolore aliqua id Lorem amet eu anim pariatur nostrud
|
||||
quis ut. Officia in aliquip minim id esse do. Magna in enim commodo
|
||||
tempor nisi voluptate cupidatat reprehenderit.
|
||||
</p>
|
||||
</template>
|
||||
</Dialog>
|
||||
</Story>
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Button @click="slotsDialog = true">Show Dialog</Button>
|
||||
<Dialog
|
||||
:options="{
|
||||
actions: [
|
||||
{ label: 'Destroy', appearance: 'danger' },
|
||||
{ label: 'Cancel' },
|
||||
],
|
||||
}"
|
||||
v-model="slotsDialog"
|
||||
>
|
||||
<template #body-title>
|
||||
<h2 class="text-3xl font-bold">My custom title</h2>
|
||||
</template>
|
||||
<template #body-content>
|
||||
<p class="mt-4 text-lg">
|
||||
Irure Lorem culpa nulla fugiat nulla labore aliquip exercitation laboris
|
||||
ex qui. Aliquip pariatur esse amet laboris. Veniam ullamco dolore
|
||||
incididunt consequat commodo id consectetur labore et. Eiusmod esse
|
||||
laborum irure aliquip laboris sunt dolore voluptate sint ea mollit do
|
||||
Lorem. Sit aute dolore aliqua id Lorem amet eu anim pariatur nostrud
|
||||
quis ut. Officia in aliquip minim id esse do. Magna in enim commodo
|
||||
tempor nisi voluptate cupidatat reprehenderit.
|
||||
</p>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
```
|
||||
|
||||
In the following example, we have used the `body` slot. This is top-most slot
|
||||
and overrides everything. Use it for fully custom dialog layouts.
|
||||
|
||||
<Story>
|
||||
<Button @click="bodyDialog = true">Show Dialog</Button>
|
||||
<Dialog v-model="bodyDialog">
|
||||
<template #body>
|
||||
<div
|
||||
class="flex items-center justify-between border-t p-2"
|
||||
v-for="i in 4"
|
||||
:key="i"
|
||||
>
|
||||
<span>Item {{ i }}</span>
|
||||
<Button icon="x" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</Story>
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Button @click="bodyDialog = true">Show Dialog</Button>
|
||||
<Dialog v-model="bodyDialog">
|
||||
<template #body>
|
||||
<div
|
||||
class="flex items-center justify-between border-t p-2"
|
||||
v-for="i in 4"
|
||||
:key="i"
|
||||
>
|
||||
<span>Item {{ i }}</span>
|
||||
<Button icon="x" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
```
|
||||
168
docs/components/dropdown.md
Normal file
168
docs/components/dropdown.md
Normal file
@ -0,0 +1,168 @@
|
||||
<script setup>
|
||||
import { Dropdown } from '../../src/index'
|
||||
let alert = text => window.alert(text)
|
||||
</script>
|
||||
|
||||
# Dropdown
|
||||
|
||||
The Dropdown component is used to show a list of options when a button is
|
||||
clicked.
|
||||
|
||||
## Usage
|
||||
|
||||
<Story class="gap-4 overflow-visible">
|
||||
<Dropdown
|
||||
:options="[
|
||||
{ label: 'Edit', icon: 'edit', handler: () => alert('New File') },
|
||||
{ label: 'Delete', icon: 'trash', handler: () => alert('New File') },
|
||||
]"
|
||||
:button="{ label: 'Menu', icon: 'more-horizontal' }"
|
||||
/>
|
||||
<Dropdown
|
||||
:options="[
|
||||
{
|
||||
group: 'New',
|
||||
hideLabel: true,
|
||||
items: [
|
||||
{
|
||||
label: 'New File',
|
||||
handler: () => alert('New File'),
|
||||
},
|
||||
{
|
||||
label: 'New Window',
|
||||
handler: () => alert('New Window'),
|
||||
// show/hide option based on condition function
|
||||
condition: () => true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Open',
|
||||
hideLabel: true,
|
||||
items: [
|
||||
{ label: 'Open File', handler: () => alert('Open File') },
|
||||
{ label: 'Open Folder' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Delete',
|
||||
items: [{ label: 'Delete File' }, { label: 'Delete Folder' }],
|
||||
},
|
||||
]"
|
||||
>
|
||||
<template v-slot="{ open }">
|
||||
<button
|
||||
:class="[
|
||||
'rounded-md px-3 py-1 text-base font-medium',
|
||||
open ? 'bg-pink-200' : 'bg-pink-100',
|
||||
]"
|
||||
>
|
||||
File
|
||||
</button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</Story>
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- basic dropdown -->
|
||||
<Dropdown
|
||||
:options="[
|
||||
{ label: 'New File', handler: () => alert('New File') },
|
||||
{ label: 'New Window' },
|
||||
]"
|
||||
:button="{ label: 'Menu', icon: 'more-horizontal' }"
|
||||
/>
|
||||
<!-- dropdown with groups and custom button -->
|
||||
<Dropdown
|
||||
:options="[
|
||||
{
|
||||
group: 'New',
|
||||
hideLabel: true,
|
||||
items: [
|
||||
{
|
||||
label: 'New File',
|
||||
handler: () => alert('New File'),
|
||||
},
|
||||
{
|
||||
label: 'New Window',
|
||||
handler: () => alert('New Window'),
|
||||
// show/hide option based on condition function
|
||||
condition: () => true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Open',
|
||||
hideLabel: true,
|
||||
items: [
|
||||
{ label: 'Open File', handler: () => alert('Open File') },
|
||||
{ label: 'Open Folder' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Delete',
|
||||
items: [{ label: 'Delete File' }, { label: 'Delete Folder' }],
|
||||
},
|
||||
]"
|
||||
>
|
||||
<template v-slot="{ open }">
|
||||
<button
|
||||
:class="[
|
||||
'rounded-md px-3 py-1 text-base font-medium',
|
||||
open ? 'bg-pink-200' : 'bg-pink-100',
|
||||
]"
|
||||
>
|
||||
File
|
||||
</button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Dropdown } from 'jingrow-ui'
|
||||
</script>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Name | Default | Value | Description |
|
||||
| :---------- | :------- | :------------------------ | :----------------------------------------------- |
|
||||
| `options` | `null` | `Array` | See [options](#options) |
|
||||
| `button` | `null` | `String` | Object that is sent as props to Button component |
|
||||
| `placement` | `'left'` | `left \| center \| right` | Placement of dropdown with respect to button |
|
||||
|
||||
## `options`
|
||||
|
||||
The only required prop for Dropdown is `options`. It can be a list of options or
|
||||
a list of groups of options.
|
||||
|
||||
```js
|
||||
// list of options
|
||||
options = [
|
||||
{
|
||||
label,
|
||||
handler,
|
||||
icon, // optional
|
||||
component, // optional
|
||||
},
|
||||
...
|
||||
]
|
||||
|
||||
// list of groups of options
|
||||
options = [
|
||||
{
|
||||
group,
|
||||
hideLabel,
|
||||
items: [
|
||||
{
|
||||
label,
|
||||
handler,
|
||||
icon, // optional
|
||||
component, // optional
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
56
docs/components/errormessage.md
Normal file
56
docs/components/errormessage.md
Normal file
@ -0,0 +1,56 @@
|
||||
<script setup>
|
||||
import { ErrorMessage } from '../../src/index'
|
||||
|
||||
let error = null
|
||||
try {
|
||||
throw new Error('An error occurred')
|
||||
} catch (e) {
|
||||
error = e
|
||||
}
|
||||
</script>
|
||||
|
||||
# ErrorMessage
|
||||
|
||||
The ErrorMessage component is used to show an error message when an action has
|
||||
failed.
|
||||
|
||||
## Usage
|
||||
|
||||
You can pass an error message as a string to `message` prop. You can also pass
|
||||
an
|
||||
[`Error`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error)
|
||||
object directly to `message` prop.
|
||||
|
||||
The component won't render if `message` is falsy. So, you don't have to write
|
||||
the `v-if` directive.
|
||||
|
||||
<Story class="gap-4 flex-col">
|
||||
<ErrorMessage message="Task failed successfully" />
|
||||
<ErrorMessage :message="error" />
|
||||
<ErrorMessage :message="null" />
|
||||
</Story>
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<ErrorMessage message="Task failed successfully" />
|
||||
<ErrorMessage :message="error" />
|
||||
<ErrorMessage :message="null" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ErrorMessage } from 'jingrow-ui'
|
||||
|
||||
let error = null
|
||||
try {
|
||||
throw new Error('An error occurred')
|
||||
} catch (e) {
|
||||
error = e
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Name | Default | Value | Description |
|
||||
| :-------- | :------ | :---------------- | :----------------------- |
|
||||
| `message` | `null` | `String \| Error` | Message to show as error |
|
||||
42
docs/components/feathericon.md
Normal file
42
docs/components/feathericon.md
Normal file
@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
import FeatherIcon from '../../src/components/FeatherIcon.vue'
|
||||
</script>
|
||||
|
||||
# FeatherIcon
|
||||
|
||||
The FeatherIcon component can be used to render SVG icons from the
|
||||
[FeatherIcons](https://feathericons.com) project.
|
||||
|
||||
## Usage
|
||||
|
||||
Setting dimensions (width and height) is required otherwise it will render in a
|
||||
large size. You can also customize the color by setting the `color` CSS
|
||||
property. You can customize the `stroke-width` by passing it as a prop.
|
||||
|
||||
<Story class="gap-4">
|
||||
<FeatherIcon name="alert-triangle" class="w-6 h-6" />
|
||||
<FeatherIcon name="chevron-right" class="w-6 h-6 text-red-600" />
|
||||
<FeatherIcon name="anchor" class="w-6 h-6 text-blue-500" stroke-width="3" />
|
||||
<FeatherIcon name="coffee" class="w-6 h-6 text-green-600" stroke-width="1" />
|
||||
</Story>
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<FeatherIcon name="alert-triangle" class="h-6 w-6" />
|
||||
<!-- custom color -->
|
||||
<FeatherIcon name="chevron-right" class="h-6 w-6 text-red-600" />
|
||||
<!-- custom stroke width -->
|
||||
<FeatherIcon name="anchor" stroke-width="3" class="h-6 w-6 text-blue-500" />
|
||||
<FeatherIcon name="coffee" stroke-width="1" class="h-6 w-6 text-green-600" />
|
||||
</template>
|
||||
<script setup>
|
||||
import { FeatherIcon } from 'jingrow-ui'
|
||||
</script>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Name | Default | Value | Description |
|
||||
| :------------- | :------- | :------- | :------------------------------------- |
|
||||
| `name` | `circle` | `String` | One of 287 icons from feathericons.com |
|
||||
| `stroke-width` | `1.5` | `Number` | |
|
||||
98
docs/components/fileuploader.md
Normal file
98
docs/components/fileuploader.md
Normal file
@ -0,0 +1,98 @@
|
||||
<script setup>
|
||||
import { FileUploader, Button, ErrorMessage } from '../../src/index'
|
||||
</script>
|
||||
|
||||
# FileUploader
|
||||
|
||||
The FileUploader component is a renderless component used to upload files. It
|
||||
only works with a Jingrow Framework backend.
|
||||
|
||||
## Usage
|
||||
|
||||
Use the default slot to render any HTML you like. A lot of slot props are
|
||||
available to render a UI that shows file progress. Make sure to call
|
||||
`openFileSelector` using a user action like a button click.
|
||||
|
||||
When the file upload is complete, the `success` event is emitted with the File
|
||||
document as JSON object.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
::: info Note
|
||||
The following example can't upload the file because it is not connected to a
|
||||
Jingrow backend.
|
||||
:::
|
||||
|
||||
<Story class="gap-4">
|
||||
<FileUploader @success="(file) => handleFile(file)">
|
||||
<template
|
||||
v-slot="{
|
||||
file,
|
||||
uploading,
|
||||
progress,
|
||||
uploaded,
|
||||
message,
|
||||
error,
|
||||
total,
|
||||
success,
|
||||
openFileSelector,
|
||||
}"
|
||||
>
|
||||
<div class="w-full text-center">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{ uploading ? `Uploading ${progress}%` : 'Upload Image' }}
|
||||
</Button>
|
||||
<ErrorMessage class="mt-2" :message="error" />
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
</Story>
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<FileUploader @success="(file) => handleFile(file)">
|
||||
<template
|
||||
v-slot="{
|
||||
file,
|
||||
uploading,
|
||||
progress,
|
||||
uploaded,
|
||||
message,
|
||||
error,
|
||||
total,
|
||||
success,
|
||||
openFileSelector,
|
||||
}"
|
||||
>
|
||||
<div class="w-full text-center">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{ uploading ? `Uploading ${progress}%` : 'Upload Image' }}
|
||||
</Button>
|
||||
<ErrorMessage class="mt-2" :message="error" />
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { FileUploader, Button } from 'jingrow-ui'
|
||||
</script>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Name | Default | Value | Description |
|
||||
| :------------- | :------ | :--------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `fileTypes` | `null` | `String` | String passed to `accept` attribute of file input. Use it to restrict file types to be uploaded. |
|
||||
| `uploadArgs` | `null` | `Object` | See [uploadArgs](#uploadargs) |
|
||||
| `validateFile` | `null` | `Function` | Validator function to validate the selected file. File object is passed as first parameter. Return an error message or throw an Error to prevent file upload. |
|
||||
|
||||
## `uploadArgs`
|
||||
|
||||
Options passed to `/api/action/upload_file` as arguments. Object structure looks
|
||||
like this:
|
||||
|
||||
```js
|
||||
{
|
||||
private, folder, file_url, pagetype, docname, fieldname, method, type
|
||||
}
|
||||
```
|
||||
52
docs/components/input.md
Normal file
52
docs/components/input.md
Normal file
@ -0,0 +1,52 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { Input } from '../../src/index'
|
||||
let fullName = ref('')
|
||||
</script>
|
||||
|
||||
# Input
|
||||
|
||||
The Input component is a prettier version of `<input />` with some additional
|
||||
features.
|
||||
|
||||
## Usage
|
||||
|
||||
<Story class="gap-4">
|
||||
<div class="w-full sm:w-1/2 space-y-4">
|
||||
<Input label="Full Name" placeholder="Jane Doe" v-model="fullName" />
|
||||
<Input label="Email" type="email" placeholder="jane@example.com" icon-left="mail" />
|
||||
<Input label="Country" type="select" :options="['India', 'Not India']" icon-left="globe" />
|
||||
<Input label="Bio" type="textarea" />
|
||||
<Input label="I have read terms and conditions" type="checkbox" />
|
||||
</div>
|
||||
</Story>
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Input label="Full Name" placeholder="Jane Doe" v-model="fullName" />
|
||||
<Input label="Email" type="email" placeholder="jane@example.com" />
|
||||
<Input label="Country" type="select" :options="['India', 'Not India']" />
|
||||
<Input label="Bio" type="textarea" />
|
||||
<Input label="I have read terms and conditions" type="checkbox" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { Input } from 'jingrow-ui'
|
||||
let fullName = ref('')
|
||||
</script>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Name | Default | Value | Description |
|
||||
| :------------ | :------- | :------------------------------------------------------------------------------ | :------------------------------------------------------ |
|
||||
| `label` | `null` | `String` | Input label |
|
||||
| `type` | `'text'` | `text \| number \| checkbox \| textarea \| select \| email \| password \| date` | Type of input |
|
||||
| `placeholder` | `null` | `String` | Input placeholder |
|
||||
| `inputClass` | `null` | `String \| Object \| Array` | Classes to apply to `input` element and not the wrapper |
|
||||
| `iconLeft` | `null` | [FeatherIcon](/components/feathericon) name | Show an icon to the left of the input |
|
||||
| `debounce` | `null` | `Number` | Debounce (in ms) applied to the `input` event |
|
||||
| `options` | `null` | `{label, value}[] \| String[]` | Only applicable if `type` is `select` |
|
||||
| `disabled` | `false` | `Boolean` | Disable input by setting this to `true` |
|
||||
| `rows` | `3` | `Number` | Only applicable if `type` is `textarea` |
|
||||
31
docs/components/loading-indicator.md
Normal file
31
docs/components/loading-indicator.md
Normal file
@ -0,0 +1,31 @@
|
||||
<script setup>
|
||||
import { LoadingIndicator } from '../../src/index'
|
||||
</script>
|
||||
|
||||
# Loading Indicator
|
||||
|
||||
This component is used to show a pending state for async and long running
|
||||
actions.
|
||||
|
||||
## Usage
|
||||
|
||||
This component doesn't accept any props. However, you can customize its size and
|
||||
color using the `class` or `style` attribute.
|
||||
|
||||
<Story class="gap-4">
|
||||
<LoadingIndicator class="w-4" />
|
||||
<LoadingIndicator class="w-5 text-gray-500" />
|
||||
<LoadingIndicator class="w-6 text-blue-500" />
|
||||
</Story>
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<LoadingIndicator class="w-4" />
|
||||
<LoadingIndicator class="w-5 text-gray-500" />
|
||||
<LoadingIndicator class="w-6 text-blue-500" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { LoadingIndicator } from 'jingrow-ui'
|
||||
</script>
|
||||
```
|
||||
192
docs/components/popover.md
Normal file
192
docs/components/popover.md
Normal file
@ -0,0 +1,192 @@
|
||||
<script setup>
|
||||
import { Popover, Button } from '../../src/index'
|
||||
let emojis = ['👍', '👎', '🔥', '🍿', '❤️']
|
||||
</script>
|
||||
|
||||
# Popover
|
||||
|
||||
The Popover component is used whenever a piece of UI needs to be shown in a
|
||||
popup.
|
||||
|
||||
## Usage
|
||||
|
||||
Popover is a headless component that will let you create custom popups. The
|
||||
`target` slot provides slotProps like `togglePopover`, `open`, `close` to
|
||||
trigger the popover. Here you can render a button or any element that will open
|
||||
the popover. In the `body` and `body-main` slots you can render the contents of
|
||||
the popover.
|
||||
|
||||
<Story class="gap-4">
|
||||
<Popover transition="default">
|
||||
<template #target="{ togglePopover, isOpen }">
|
||||
<Button
|
||||
appearance="minimal"
|
||||
icon-left="smile"
|
||||
@click="togglePopover()"
|
||||
:active="isOpen"
|
||||
>
|
||||
Feedback
|
||||
</Button>
|
||||
</template>
|
||||
<template #body-main>
|
||||
<div class="grid grid-cols-5 p-0.5">
|
||||
<button
|
||||
class="grid h-8 w-8 place-items-center rounded p-1 hover:bg-gray-100"
|
||||
v-for="emoji in emojis"
|
||||
>
|
||||
{{ emoji }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</Story>
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Popover transition="default">
|
||||
<template #target="{ togglePopover, isOpen }">
|
||||
<Button
|
||||
appearance="minimal"
|
||||
icon-left="smile"
|
||||
@click="togglePopover()"
|
||||
:active="isOpen"
|
||||
>
|
||||
Feedback
|
||||
</Button>
|
||||
</template>
|
||||
<template #body-main>
|
||||
<div class="grid grid-cols-5 p-0.5">
|
||||
<button
|
||||
class="grid h-8 w-8 place-items-center rounded p-1 hover:bg-gray-100"
|
||||
v-for="emoji in emojis"
|
||||
>
|
||||
{{ emoji }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Popover } from 'jingrow-ui'
|
||||
</script>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Name | Default | Value | Description |
|
||||
| :------------- | :--------------- | :------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------- |
|
||||
| `trigger` | `'click'` | `click \| hover` | See [trigger](#trigger) |
|
||||
| `hoverDelay` | `0` | `Number` in seconds | Only applicable if `trigger` is `hover` |
|
||||
| `leaveDelay` | `0` | `Number` in seconds | Only applicable if `trigger` is `hover` |
|
||||
| `placement` | `'bottom-start'` | `top-start \| top \| top-end \| bottom-start \| bottom \| bottom-end \| right-start \| right \| right-end \| left-start \| left \| left-end` | Placement of the popup with respect to the trigger |
|
||||
| `popoverClass` | `null` | `String` | Class to apply to the popover container |
|
||||
| `transition` | `null` | `Object \| 'default'` | See [transition](#transition) |
|
||||
| `hideOnBlur` | `true` | `Boolean` | Whether to close the popup on clicking outside |
|
||||
| `show` | `undefined` | `Boolean` | Control when popup shows based on this prop |
|
||||
|
||||
### `trigger`
|
||||
|
||||
The trigger prop allows you control whether the popup should open on `click` of
|
||||
the target element or `hover`. If you set `trigger` as `hover` you get two more
|
||||
props to control its behaviour: `hoverDelay` and `leaveDelay`. If you keep your
|
||||
mouse pointer on the popup content the popup wont close.
|
||||
|
||||
<Story class="gap-4">
|
||||
<Popover trigger="hover" :hoverDelay="0.5" :leaveDelay="1">
|
||||
<template #target>
|
||||
<Button>Hover me</Button>
|
||||
</template>
|
||||
<template #body-main>
|
||||
<div class="p-2 text-base">Popup content</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</Story>
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Popover trigger="hover" :hoverDelay="0.5" :leaveDelay="1">
|
||||
<template #target>
|
||||
<Button>Hover me</Button>
|
||||
</template>
|
||||
<template #body-main>
|
||||
<div class="p-2 text-base">Popup content</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
```
|
||||
|
||||
### `transition`
|
||||
|
||||
The transition prop is an object that can be used to add transitions the enter
|
||||
and exit states of the popup. Internally, this prop is directly passed to the
|
||||
`transition` component.
|
||||
|
||||
<Story class="gap-4">
|
||||
<Popover transition="default" trigger="hover">
|
||||
<template #target>
|
||||
<Button>Default transition</Button>
|
||||
</template>
|
||||
<template #body-main>
|
||||
<div class="p-2 text-base">Popup content</div>
|
||||
</template>
|
||||
</Popover>
|
||||
<Popover
|
||||
trigger="hover"
|
||||
:transition="{
|
||||
enterActiveClass: 'transition duration-500 ease-in-out origin-top-left',
|
||||
enterFromClass: 'scale-0',
|
||||
enterToClass: 'scale-100',
|
||||
leaveActiveClass: 'transition duration-300 ease-in-out origin-top-right',
|
||||
leaveFromClass: 'scale-100',
|
||||
leaveToClass: 'scale-0',
|
||||
}"
|
||||
>
|
||||
<template #target>
|
||||
<Button>Custom Transition</Button>
|
||||
</template>
|
||||
<template #body-main>
|
||||
<div class="p-2 text-base">Popup content</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</Story>
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Popover transition="default" trigger="hover">
|
||||
<template #target>
|
||||
<Button>Default transition</Button>
|
||||
</template>
|
||||
<template #body-main>
|
||||
<div class="p-2 text-base">Popup content</div>
|
||||
</template>
|
||||
</Popover>
|
||||
|
||||
<Popover
|
||||
trigger="hover"
|
||||
:transition="{
|
||||
enterActiveClass: 'transition duration-500 ease-in-out origin-top-left',
|
||||
enterFromClass: 'scale-0',
|
||||
enterToClass: 'scale-100',
|
||||
leaveActiveClass: 'transition duration-300 ease-in-out origin-top-right',
|
||||
leaveFromClass: 'scale-100',
|
||||
leaveToClass: 'scale-0',
|
||||
}"
|
||||
>
|
||||
<template #target>
|
||||
<Button>Custom Transition</Button>
|
||||
</template>
|
||||
<template #body-main>
|
||||
<div class="p-2 text-base">Popup content</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Slots
|
||||
|
||||
| Name | Description |
|
||||
| :---------- | :------------------------------------------------------------------------------ |
|
||||
| `target` | Reference element against which the popup is positioned |
|
||||
| `body-main` | Popup content rendered inside a `div` with white background and rounded corners |
|
||||
| `body` | Popup content without any markup |
|
||||
56
docs/components/resource.md
Normal file
56
docs/components/resource.md
Normal file
@ -0,0 +1,56 @@
|
||||
<script setup>
|
||||
import { Resource, Button } from '../../src/index'
|
||||
</script>
|
||||
|
||||
# Resource
|
||||
|
||||
This is a headless component wrapper over [Resource](/resources/resource).
|
||||
|
||||
## Usage
|
||||
|
||||
The Resource component does not render any markup. It provides the resource
|
||||
object via slotProps.
|
||||
|
||||
<Story>
|
||||
<Resource
|
||||
:options="{
|
||||
url: 'https://jsonplaceholder.typicode.com/users/1',
|
||||
}"
|
||||
v-slot="{ resource }"
|
||||
>
|
||||
<div class="w-full">
|
||||
<Button @click="resource.fetch()" :loading="resource.loading">
|
||||
Fetch User
|
||||
</Button>
|
||||
<pre>{{ resource.data }}</pre>
|
||||
</div>
|
||||
</Resource>
|
||||
</Story>
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Resource
|
||||
:options="{
|
||||
url: 'https://jsonplaceholder.typicode.com/users/1',
|
||||
}"
|
||||
v-slot="{ resource }"
|
||||
>
|
||||
<div class="w-full">
|
||||
<Button @click="resource.fetch()" :loading="resource.loading">
|
||||
Fetch Post
|
||||
</Button>
|
||||
<pre>{{ resource.data }}</pre>
|
||||
</div>
|
||||
</Resource>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Resource, Button } from 'jingrow-ui'
|
||||
</script>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Name | Default | Value |
|
||||
| :-------- | :------ | :------------------------------------------------------------------- |
|
||||
| `options` | `null` | [Resource Options](/resources/resource.html#list-of-options-and-api) |
|
||||
366
docs/components/text-editor.md
Normal file
366
docs/components/text-editor.md
Normal file
@ -0,0 +1,366 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { TextEditor, TextEditorFixedMenu, TextEditorBubbleMenu, TextEditorContent, Button, Input } from '../../src/index'
|
||||
let content1 = ref(`<p><strong>Hello, we are Jingrow 👋</strong></p><p>We are a remote technology company committed to building world-class <mark data-color="#fef9c3" style="background-color: #fef9c3; color: inherit">open-source</mark> software products and services. This is our story.</p><p><strong>Framework and Apps</strong></p><p>Our flagship products are our web framework <a target="_blank" rel="noopener noreferrer nofollow" href="https://framework.jingrow.com/">Jingrow</a> which is a fully featured, low code framework, and the world's best free and open source ERP <a target="_blank" rel="noopener noreferrer nofollow" href="https://erpnext.com/">ERPNext</a>. ERPNext helps companies from tiny startups to large enterprises and public bodies manage their operations from financial accounting, inventory management to payroll. Along with ERPNext we have built <a target="_blank" rel="noopener noreferrer nofollow" href="https://framework.jingrow.com">several other products</a> on top of our framework and we continue to build more.</p><p>In case you are wondering, JINGROW = FRamework + APPs :-)</p><p><strong>Products</strong></p><ul><li><p><a target="_blank" rel="noopener noreferrer nofollow" href="https://jcloud.jingrow.com/"><strong>Jingrow Cloud</strong></a></p></li><li><p><a target="_blank" rel="noopener noreferrer nofollow" href="https://github.com/jingrow/gameplan"><strong>Gameplan</strong></a></p></li><li><p><a target="_blank" rel="noopener noreferrer nofollow" href="https://jingrowdesk.com/"><strong>Jingrow Desk</strong></a></p></li><li><p><a target="_blank" rel="noopener noreferrer nofollow" href="https://github.com/jingrow/insights"><strong>Jingrow Insights</strong></a></p></li><li><p><a target="_blank" rel="noopener noreferrer nofollow" href="https://github.com/jingrow/drive"><strong>Jingrow Drive</strong></a></p></li></ul>`)
|
||||
let content2 = ref('Highly customized editor')
|
||||
let contentBubble = ref('Highlight some text here')
|
||||
let editable = ref(true)
|
||||
let fixedMenuMinimalButtons = [
|
||||
'Paragraph',
|
||||
['Heading 2', 'Heading 3', 'Heading 4', 'Heading 5', 'Heading 6'],
|
||||
'Separator',
|
||||
'Bold',
|
||||
'Italic',
|
||||
'Separator',
|
||||
'Bullet List',
|
||||
'Numbered List',
|
||||
'Separator',
|
||||
'Link',
|
||||
'Blockquote',
|
||||
'Code',
|
||||
]
|
||||
let fixedMenuButtons = [
|
||||
'Paragraph',
|
||||
['Heading 2', 'Heading 3', 'Heading 4', 'Heading 5', 'Heading 6'],
|
||||
'Separator',
|
||||
'Bold',
|
||||
'Italic',
|
||||
'Separator',
|
||||
'Bullet List',
|
||||
'Numbered List',
|
||||
'Separator',
|
||||
'Align Left',
|
||||
'Align Center',
|
||||
'Align Right',
|
||||
'FontColor',
|
||||
'Separator',
|
||||
'Image',
|
||||
'Video',
|
||||
'Link',
|
||||
'Blockquote',
|
||||
'Code',
|
||||
'Horizontal Rule',
|
||||
[
|
||||
'InsertTable',
|
||||
'AddColumnBefore',
|
||||
'AddColumnAfter',
|
||||
'DeleteColumn',
|
||||
'AddRowBefore',
|
||||
'AddRowAfter',
|
||||
'DeleteRow',
|
||||
'MergeCells',
|
||||
'SplitCell',
|
||||
'ToggleHeaderColumn',
|
||||
'ToggleHeaderRow',
|
||||
'ToggleHeaderCell',
|
||||
'DeleteTable',
|
||||
],
|
||||
]
|
||||
let mentions = [
|
||||
{ label: "Husain Wrenn", value: "hwrenn0@spotify.com" },
|
||||
{ label: "Gwenore Fitter", value: "gfitter1@foxnews.com" },
|
||||
{ label: "Ricard Claussen", value: "rclaussen2@imgur.com" },
|
||||
{ label: "Rickard Higford", value: "rhigford3@multiply.com" },
|
||||
{ label: "Lazarus MacKey", value: "lmackey4@prlog.org" },
|
||||
{ label: "Karrah Ege", value: "kege5@prweb.com" },
|
||||
{ label: "Seward Godin", value: "sgodin6@msu.edu" },
|
||||
{ label: "Milzie Sanches", value: "msanches7@senate.gov" },
|
||||
{ label: "Walt Arrington", value: "warrington8@tripod.com" },
|
||||
{ label: "Seline Bonifas", value: "sbonifas9@hibu.com" },
|
||||
];
|
||||
</script>
|
||||
|
||||
# Text Editor
|
||||
|
||||
The Text Editor component is used for rich-text editing. It is based on
|
||||
[Tiptap](https://tiptap.dev).
|
||||
|
||||
## Usage
|
||||
|
||||
The TextEditor component is very flexible in terms of layout and features. It
|
||||
provides building blocks like menus and slots for building any type of editor
|
||||
experience you might want.
|
||||
|
||||
Here is a basic version with fixed menu at the top.
|
||||
|
||||
<Story class="h-80" :iframe="false">
|
||||
<TextEditor
|
||||
editor-class="!prose-sm border max-w-none rounded-b-lg p-3 overflow-auto h-64 focus:outline-none"
|
||||
:fixedMenu="true"
|
||||
:content="content1"
|
||||
@change="val => content1 = val"
|
||||
:mentions="mentions"
|
||||
/>
|
||||
</Story>
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<TextEditor
|
||||
editor-class="prose-sm border max-w-none rounded-b-lg p-3 overflow-auto h-64 focus:outline-none"
|
||||
:fixedMenu="true"
|
||||
:content="content"
|
||||
@change="(val) => (content = val)"
|
||||
:mentions="mentions"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { TextEditor } from 'jingrow-ui'
|
||||
let mentions = [
|
||||
{ label: 'Husain Wrenn', value: 'hwrenn0@spotify.com' },
|
||||
{ label: 'Gwenore Fitter', value: 'gfitter1@foxnews.com' },
|
||||
{ label: 'Ricard Claussen', value: 'rclaussen2@imgur.com' },
|
||||
{ label: 'Rickard Higford', value: 'rhigford3@multiply.com' },
|
||||
{ label: 'Lazarus MacKey', value: 'lmackey4@prlog.org' },
|
||||
{ label: 'Karrah Ege', value: 'kege5@prweb.com' },
|
||||
{ label: 'Seward Godin', value: 'sgodin6@msu.edu' },
|
||||
{ label: 'Milzie Sanches', value: 'msanches7@senate.gov' },
|
||||
{ label: 'Walt Arrington', value: 'warrington8@tripod.com' },
|
||||
{ label: 'Seline Bonifas', value: 'sbonifas9@hibu.com' },
|
||||
]
|
||||
</script>
|
||||
```
|
||||
|
||||
If you are on Vue version 3.2 or earlier, you need to add this line in your main.js file:
|
||||
```js
|
||||
app.config.unwrapInjectedRef = true
|
||||
```
|
||||
You can read more about it here: https://vuejs.org/guide/components/provide-inject.html#working-with-reactivity
|
||||
|
||||
|
||||
## Props
|
||||
|
||||
| Name | Default | Value | Description |
|
||||
| :------------------ | :------ | :-------------------------- | :----------------------------------------------------------------------------------------------------- |
|
||||
| `content` | `null` | `String` | HTML string to set as the initial value in the editor |
|
||||
| `placeholder` | `null` | `String` | Placeholder text to show when the content is empty |
|
||||
| `editorClass` | `null` | `String \| Object \| Array` | Valid [CSS class values](https://vuejs.org/guide/essentials/class-and-style.html#binding-html-classes) |
|
||||
| `editable` | `true` | `Boolean` | Enable/disable editing |
|
||||
| `fixedMenu` | `null` | `true \| false \| Array` | See [customizing menu](#customizing-menu) |
|
||||
| `bubbleMenu` | `null` | `true \| false \| Array` | See [customizing menu](#customizing-menu) |
|
||||
| `floatingMenu` | `null` | `true \| false \| Array` | See [customizing menu](#customizing-menu) |
|
||||
| `extensions` | `null` | `Array` | [Tiptap extensions](https://tiptap.dev/extensions) |
|
||||
| `starterkitOptions` | `null` | `Object` | Options to pass to the [Starterkit Extension](https://tiptap.dev/api/extensions/starter-kit) |
|
||||
| `mentions` | `null` | `Array` | Array of `{label, value}` for mentions list |
|
||||
|
||||
## Customizing Menu
|
||||
|
||||
There are three types of menus available for the editor.
|
||||
|
||||
- Fixed Menu: Menu that is always visible at a fixed place
|
||||
- Bubble Menu: Menu that shows up when you select some text
|
||||
- Floating Menu: Menu that shows up on a new line
|
||||
|
||||
You can choose to use any of these or a combination of these in your editor.
|
||||
Here are some examples of customized editors:
|
||||
|
||||
### Fixed menu with custom buttons
|
||||
|
||||
You can customize which buttons show up in the menu by passing an array of
|
||||
button names. You can find the list of all buttons available
|
||||
[here](https://github.com/jingrow/jingrow-ui/blob/main/src/components/TextEditor/commands.js).
|
||||
|
||||
<Story class="h-80" :iframe="false">
|
||||
<TextEditor
|
||||
editor-class="!prose-sm border max-w-none rounded-b-lg p-3 overflow-auto h-64 focus:outline-none"
|
||||
:fixedMenu="fixedMenuMinimalButtons"
|
||||
:content="content1"
|
||||
@change="val => content1 = val"
|
||||
/>
|
||||
</Story>
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<TextEditor
|
||||
editor-class="!prose-sm border max-w-none rounded-b-lg p-3 overflow-auto h-64 focus:outline-none"
|
||||
:fixedMenu="fixedMenuMinimalButtons"
|
||||
:content="content"
|
||||
@change="(val) => (content = val)"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { TextEditor } from 'jingrow-ui'
|
||||
let content = ref('[initial html content]')
|
||||
let fixedMenuMinimalButtons = [
|
||||
'Paragraph',
|
||||
['Heading 2', 'Heading 3', 'Heading 4', 'Heading 5', 'Heading 6'],
|
||||
'Separator',
|
||||
'Bold',
|
||||
'Italic',
|
||||
'Separator',
|
||||
'Bullet List',
|
||||
'Numbered List',
|
||||
'Separator',
|
||||
'Link',
|
||||
'Blockquote',
|
||||
'Code',
|
||||
]
|
||||
</script>
|
||||
```
|
||||
|
||||
### Minimal editor with bubble menu
|
||||
|
||||
Setting `bubbleMenu` to `true` will show a bubble menu on text selection. You
|
||||
can also pass a list of button names, just like the previous example.
|
||||
|
||||
<Story class="h-28" :iframe="false">
|
||||
<TextEditor
|
||||
editor-class="!prose-sm border max-w-none rounded-lg p-3 overflow-auto h-20 focus:outline-none"
|
||||
:bubbleMenu="true"
|
||||
:content="contentBubble"
|
||||
@change="val => contentBubble = val"
|
||||
/>
|
||||
</Story>
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<TextEditor
|
||||
editor-class="!prose-sm border max-w-none rounded-lg p-3 overflow-auto h-20 focus:outline-none"
|
||||
:bubbleMenu="true"
|
||||
:content="content"
|
||||
@change="(val) => (content = val)"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { TextEditor } from 'jingrow-ui'
|
||||
let content = ref('[initial html content]')
|
||||
</script>
|
||||
```
|
||||
|
||||
### Floating menu and bubble menu
|
||||
|
||||
You can combine multiple menus. You can also pass an array of button names.
|
||||
|
||||
<Story class="h-80" :iframe="false">
|
||||
<TextEditor
|
||||
editor-class="!prose-sm border max-w-none rounded-lg p-3 overflow-auto h-72 focus:outline-none"
|
||||
:floatingMenu="true"
|
||||
:bubbleMenu="true"
|
||||
placeholder="Type something and press enter"
|
||||
/>
|
||||
</Story>
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<TextEditor
|
||||
editor-class="!prose-sm border max-w-none rounded-lg p-3 overflow-auto h-20 focus:outline-none"
|
||||
:floatingMenu="true"
|
||||
:bubbleMenu="true"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { TextEditor } from 'jingrow-ui'
|
||||
</script>
|
||||
```
|
||||
|
||||
### Comment Editor
|
||||
|
||||
An example of how to use slots and various features of the TextEditor to make a
|
||||
customized editor experience.
|
||||
|
||||
There are three slots available: `top`, `bottom` and `editor`. The `top` and
|
||||
`bottom` are slots for placing menus at the top or bottom of the editor. If you
|
||||
are using these slots, you must import and render the Menu components manually.
|
||||
They are available to import as `TextEditorFixedMenu`, `TextEditorBubbleMenu`,
|
||||
and `TextEditorFloatingMenu`.
|
||||
|
||||
The `editor` slot renders the `TextEditorContent` component, you can override it
|
||||
and render the `TextEditorContent` component if you want some custom behaviour.
|
||||
|
||||
<Story class="h-[15rem] !block" :iframe="false">
|
||||
<Input class="mb-2" type="checkbox" v-model="editable" label="Editable" />
|
||||
<TextEditor
|
||||
class="border p-4 rounded-lg"
|
||||
:editor-class="['prose-sm max-w-none min-h-[6rem]']"
|
||||
:content="content2"
|
||||
@change="val => content2 = val"
|
||||
:starterkit-options="{ heading: { levels: [2, 3, 4, 5, 6] } }"
|
||||
placeholder="Write something..."
|
||||
:editable="editable"
|
||||
>
|
||||
<template v-slot:editor="{ editor }">
|
||||
<TextEditorContent
|
||||
:class="[editable && 'max-h-[6rem] overflow-y-auto']"
|
||||
:editor="editor"
|
||||
/>
|
||||
</template>
|
||||
<template v-slot:bottom>
|
||||
<div
|
||||
v-if="editable"
|
||||
class="mt-2 flex flex-col justify-between sm:flex-row sm:items-center"
|
||||
>
|
||||
<TextEditorFixedMenu
|
||||
class="-ml-1 overflow-x-auto"
|
||||
:buttons="fixedMenuMinimalButtons"
|
||||
/>
|
||||
<div class="mt-2 flex items-center justify-end space-x-2 sm:mt-0">
|
||||
<Button appearance="primary">
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</TextEditor>
|
||||
</Story>
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Input class="mb-2" type="checkbox" v-model="editable" label="Editable" />
|
||||
<TextEditor
|
||||
class="rounded-lg border p-4"
|
||||
:editor-class="['prose-sm max-w-none min-h-[6rem]']"
|
||||
:content="content2"
|
||||
@change="(val) => (content2 = val)"
|
||||
:starterkit-options="{ heading: { levels: [2, 3, 4, 5, 6] } }"
|
||||
placeholder="Write something..."
|
||||
:editable="editable"
|
||||
>
|
||||
<template v-slot:editor="{ editor }">
|
||||
<TextEditorContent
|
||||
:class="[editable && 'max-h-[6rem] overflow-y-auto']"
|
||||
:editor="editor"
|
||||
/>
|
||||
</template>
|
||||
<template v-slot:bottom>
|
||||
<div
|
||||
v-if="editable"
|
||||
class="mt-2 flex flex-col justify-between sm:flex-row sm:items-center"
|
||||
>
|
||||
<TextEditorFixedMenu
|
||||
class="-ml-1 overflow-x-auto"
|
||||
:buttons="fixedMenuMinimalButtons"
|
||||
/>
|
||||
<div class="mt-2 flex items-center justify-end space-x-2 sm:mt-0">
|
||||
<Button appearance="primary"> Submit </Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</TextEditor>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
TextEditor,
|
||||
TextEditorFixedMenu,
|
||||
TextEditorContent,
|
||||
Button,
|
||||
Input,
|
||||
} from 'jingrow-ui'
|
||||
let content = ref('Highly customized editor')
|
||||
let editable = ref(true)
|
||||
let fixedMenuMinimalButtons = [
|
||||
'Paragraph',
|
||||
['Heading 2', 'Heading 3', 'Heading 4', 'Heading 5', 'Heading 6'],
|
||||
'Separator',
|
||||
'Bold',
|
||||
'Italic',
|
||||
'Separator',
|
||||
'Bullet List',
|
||||
'Numbered List',
|
||||
'Separator',
|
||||
'Link',
|
||||
'Blockquote',
|
||||
'Code',
|
||||
]
|
||||
</script>
|
||||
```
|
||||
135
docs/components/toast.md
Normal file
135
docs/components/toast.md
Normal file
@ -0,0 +1,135 @@
|
||||
<script setup>
|
||||
import { Toasts, Toast, toast, Button } from '../../src/index'
|
||||
let makeToast = (props = {}) => toast({
|
||||
title: 'Success',
|
||||
text: 'File Uploaded Successfully!',
|
||||
timeout: 2,
|
||||
...props
|
||||
})
|
||||
</script>
|
||||
|
||||
# Toast
|
||||
|
||||
This component is used to show a message in a floating box relative to the
|
||||
browser window.
|
||||
|
||||
## Usage
|
||||
|
||||
Call the `toast` function with options to create a toast. You need to make sure
|
||||
you include the `Toasts` component in your root component
|
||||
([`App.vue`](#app-vue)).
|
||||
|
||||
<Story>
|
||||
<Button
|
||||
@click="
|
||||
toast({
|
||||
title: 'Success',
|
||||
text: 'File Uploaded Successfully!',
|
||||
icon: 'check',
|
||||
iconClasses: 'text-green-500',
|
||||
})
|
||||
"
|
||||
>
|
||||
Show Toast
|
||||
</Button>
|
||||
<Toasts />
|
||||
</Story>
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Button
|
||||
@click="
|
||||
toast({
|
||||
title: 'Success',
|
||||
text: 'File Uploaded Successfully!',
|
||||
icon: 'check',
|
||||
iconClasses: 'text-green-500',
|
||||
})
|
||||
"
|
||||
>
|
||||
Show Toast
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { toast, Button } from 'jingrow-ui'
|
||||
</script>
|
||||
```
|
||||
|
||||
### App.vue
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- your markup -->
|
||||
<Toasts />
|
||||
</template>
|
||||
<script setup>
|
||||
import { Toasts } from 'jingrow-ui'
|
||||
</script>
|
||||
```
|
||||
|
||||
### `position`
|
||||
|
||||
Toasts can be positioned in six places with respect to the browser window.
|
||||
|
||||
- `top-left`
|
||||
- `top-center`
|
||||
- `top-right`
|
||||
- `bottom-left`
|
||||
- `bottom-center`
|
||||
- `bottom-right`
|
||||
|
||||
<Story>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<Button @click="makeToast({ position: 'top-left' })"> Top Left </Button>
|
||||
<Button @click="makeToast({ position: 'top-center' })"> Top Center </Button>
|
||||
<Button @click="makeToast({ position: 'top-right' })"> Top Right </Button>
|
||||
<Button @click="makeToast({ position: 'bottom-left' })">
|
||||
Bottom Left
|
||||
</Button>
|
||||
<Button @click="makeToast({ position: 'bottom-center' })">
|
||||
Bottom Center
|
||||
</Button>
|
||||
<Button @click="makeToast({ position: 'bottom-right' })">
|
||||
Bottom Right
|
||||
</Button>
|
||||
</div>
|
||||
<Toasts />
|
||||
</Story>
|
||||
|
||||
<!-- prettier-ignore-start -->
|
||||
```vue
|
||||
<template>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<Button @click="makeToast({ position: 'top-left' })"> Top Left </Button>
|
||||
<Button @click="makeToast({ position: 'top-center' })"> Top Center </Button>
|
||||
<Button @click="makeToast({ position: 'top-right' })"> Top Right </Button>
|
||||
<Button @click="makeToast({ position: 'bottom-left' })"> Bottom Left </Button>
|
||||
<Button @click="makeToast({ position: 'bottom-center' })"> Bottom Center </Button>
|
||||
<Button @click="makeToast({ position: 'bottom-right' })"> Bottom Right </Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Tooltip, Button } from 'jingrow-ui'
|
||||
|
||||
let makeToast = (props = {}) => toast({
|
||||
title: 'Success',
|
||||
text: 'File Uploaded Successfully!',
|
||||
timeout: 2,
|
||||
...props
|
||||
})
|
||||
</script>
|
||||
```
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
## Options
|
||||
|
||||
| Name | Default | Value |
|
||||
| :------------ | :------------- | :-------------------------------------------- |
|
||||
| `title` | `null` | `String` |
|
||||
| `text` | `null` | `String` |
|
||||
| `timeout` | `5` | `Number` in seconds |
|
||||
| `position` | `'top-center'` | See [position](#position) |
|
||||
| `icon` | `null` | [FeatherIcon](/components/feathericon) name |
|
||||
| `iconClasses` | `null` | CSS Classes to apply to FeatherIcon component |
|
||||
42
docs/components/tooltip.md
Normal file
42
docs/components/tooltip.md
Normal file
@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
import { Tooltip, Button } from '../../src/index'
|
||||
</script>
|
||||
|
||||
# Tooltip
|
||||
|
||||
This component is a wrapper over the [Popover](/components/popover) component
|
||||
for an easy way to show tooltips on hover over elements.
|
||||
|
||||
## Usage
|
||||
|
||||
<Story class="gap-4 flex-col">
|
||||
<Tooltip text="Edit Post">
|
||||
<Button icon="edit" label="Edit Post" />
|
||||
</Tooltip>
|
||||
</Story>
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Tooltip text="Edit Post">
|
||||
<Button icon="edit" label="Edit" />
|
||||
</Tooltip>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Tooltip, Button } from 'jingrow-ui'
|
||||
</script>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Name | Default | Value |
|
||||
| :----------- | :------ | :------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `text` | `null` | `String` |
|
||||
| `hoverDelay` | `0.5` | `Number` in seconds |
|
||||
| `placement` | `'top'` | `top-start \| top \| top-end \| bottom-start \| bottom \| bottom-end \| right-start \| right \| right-end \| left-start \| left \| left-end` |
|
||||
|
||||
## Slots
|
||||
|
||||
| Name | Description |
|
||||
| :-------- | :------------------------------------------- |
|
||||
| `default` | Default slot to render the reference element |
|
||||
76
docs/other/Directives.story.md
Normal file
76
docs/other/Directives.story.md
Normal file
@ -0,0 +1,76 @@
|
||||
# Directives
|
||||
|
||||
Some common Vue directives that are useful in building frontend apps.
|
||||
|
||||
## onOutsideClick
|
||||
|
||||
This directive can be used to listen for click events outside of the target
|
||||
element. In the following example, the div with border has the directive, so
|
||||
clicks outside the div will trigger the `setInactive` function.
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="rounded-lg border p-8" v-on-outside-click="setInactive">
|
||||
<Button>{{ active ? 'Click outside' : 'Click me' }}</Button>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { onOutsideClickDirective } from 'jingrow-ui'
|
||||
export default {
|
||||
directives: {
|
||||
onOutsideClick: onOutsideClickDirective,
|
||||
},
|
||||
data() {
|
||||
return { active: false }
|
||||
},
|
||||
methods: {
|
||||
setInactive() {
|
||||
this.active = false
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## visibility
|
||||
|
||||
This directive allows you to trigger a function whenever an element becomes
|
||||
visible in the viewport. In the following example, whenever the visibility of
|
||||
the gray box is changed, the values `visible` and `intersectionRatio` are
|
||||
updated. This feature internally uses the
|
||||
[IntersectionObserver API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API),
|
||||
and the second parameter to the function is the
|
||||
[`entry` object](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry).
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<pre>visible: {{ visible }}</pre>
|
||||
<pre>intersectionRatio: {{ intersectionRatio }}</pre>
|
||||
<div
|
||||
class="mt-20 h-20 w-full rounded-lg bg-gray-100 p-8"
|
||||
v-visibility="onVisibilityChange"
|
||||
>
|
||||
<pre>target element</pre>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { visibilityDirective } from 'jingrow-ui'
|
||||
export default {
|
||||
directives: {
|
||||
visibility: visibilityDirective,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
visible: false,
|
||||
intersectionRatio: 0,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onVisibilityChange(visible, entry) {
|
||||
this.visible = visible
|
||||
this.intersectionRatio = entry.intersectionRatio
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
```
|
||||
66
docs/other/Utilities.story.md
Normal file
66
docs/other/Utilities.story.md
Normal file
@ -0,0 +1,66 @@
|
||||
# Utilities
|
||||
|
||||
Some common utilities that are useful in building frontend apps.
|
||||
|
||||
## debounce
|
||||
|
||||
Creates a function that will run only once in the specified number of wait time
|
||||
(milliseconds). In the following example, if you run `debouncedInput` function
|
||||
every time the user presses a key, it will run only once in every `500ms`.
|
||||
|
||||
```js
|
||||
import { debounce } from 'jingrow-ui'
|
||||
|
||||
function onInput(e) {
|
||||
// do something with input event
|
||||
}
|
||||
|
||||
let debouncedInput = debounce(onInput, 500)
|
||||
```
|
||||
|
||||
## fileToBase64
|
||||
|
||||
This function will return the base64 string of a
|
||||
[File object](https://developer.mozilla.org/en-US/docs/Web/API/File_API).
|
||||
|
||||
```js
|
||||
import { fileToBase64 } from 'jingrow-ui'
|
||||
|
||||
let base64 = fileToBase64(file) // file must be an instance of File
|
||||
```
|
||||
|
||||
## pageMeta
|
||||
|
||||
This is a plugin that can be used to update the `document.title` reactively as
|
||||
the page changes.
|
||||
|
||||
Register the plugin in your `main.js` file.
|
||||
|
||||
```js
|
||||
import { pageMetaPlugin } from 'jingrow-ui'
|
||||
// ...
|
||||
app.use(pageMetaPlugin)
|
||||
```
|
||||
|
||||
Now, in your page component, declare the `pageMeta` function. It must return an
|
||||
object with `title` and (`icon` or `emoji`) properties. The `pageMeta` function
|
||||
behaves like a computed property, if there are reactive dependences that change,
|
||||
`document.title` will also change accordingly.
|
||||
|
||||
**Page.vue**
|
||||
|
||||
```vue
|
||||
<template>...</template>
|
||||
<script>
|
||||
export default {
|
||||
...
|
||||
pageMeta() {
|
||||
return {
|
||||
title: 'Page Title',
|
||||
icon: '<link to .png, .ico file>',
|
||||
emoji: '🌈'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
168
docs/resources/Document Resource.story.md
Normal file
168
docs/resources/Document Resource.story.md
Normal file
@ -0,0 +1,168 @@
|
||||
# Document Resource
|
||||
|
||||
Document Resource is a wrapper on top of [Resource](./Resource.story.md) for
|
||||
working with a single document. This feature only works with a Jingrow Framework
|
||||
backend as of now.
|
||||
|
||||
## Usage
|
||||
|
||||
Create a document resource by specifying `pagetype` and `name` of the record. It
|
||||
will be fetched automatically. The `todo.pg` is the document object with all
|
||||
the fields of the document. Along with this, you get `todo.setValue` and
|
||||
`todo.delete` resources.
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div v-if="todo.pg">
|
||||
<div>
|
||||
<h1>
|
||||
{{ todo.description }}
|
||||
</h1>
|
||||
<Badge>{{ todo.status }}</Badge>
|
||||
</div>
|
||||
<Button @click="todo.setValue.submit({ status: 'Closed' })">
|
||||
Mark as Closed
|
||||
</Button>
|
||||
<Button @click="todo.sendEmail.submit({ email: todo.owner })">
|
||||
Send email
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createDocumentResource, Button } from 'jingrow-ui'
|
||||
let todo = createDocumentResource({
|
||||
pagetype: 'ToDo',
|
||||
name: '1',
|
||||
whitelistedMethods: {
|
||||
sendEmail: 'send_email',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Options API
|
||||
|
||||
You can also define resources if you are using Options API. You need to register
|
||||
the `resourcesPlugin` first.
|
||||
|
||||
**main.js**
|
||||
|
||||
```js
|
||||
import { resourcesPlugin } from 'jingrow-ui'
|
||||
app.use(resourcesPlugin)
|
||||
```
|
||||
|
||||
In your .vue file, you can declare all your resources under the resources key as
|
||||
functions. The resource object will be available on `this.$resources.[name]`. In
|
||||
the following example, `this.$resources.todo` is the resource object.
|
||||
|
||||
**Component.vue**
|
||||
|
||||
```vue
|
||||
<script>
|
||||
export default {
|
||||
resources: {
|
||||
todo() {
|
||||
return {
|
||||
type: 'document',
|
||||
pagetype: 'ToDo',
|
||||
name: '1',
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## List of Options and API
|
||||
|
||||
Here is the list of all options and APIs that are available on a list resource.
|
||||
|
||||
### Options
|
||||
|
||||
```js
|
||||
let todo = createDocumentResource({
|
||||
// name of the pagetype
|
||||
pagetype: 'ToDo',
|
||||
|
||||
// name of the record
|
||||
name: '',
|
||||
|
||||
// define pg methods to use as resources
|
||||
whitelistedMethods: {
|
||||
sendEmail: 'send_email',
|
||||
},
|
||||
// the above configuration enables the following API
|
||||
// todo.sendEmail.submit()
|
||||
|
||||
// events
|
||||
// error can occur from failed request
|
||||
onError(error) {},
|
||||
// on successful response
|
||||
onSuccess(data) {},
|
||||
// transform data before setting it
|
||||
transform(pg) {
|
||||
pg.open = false
|
||||
return pg
|
||||
},
|
||||
// other events
|
||||
delete: {
|
||||
onSuccess() {},
|
||||
onError() {},
|
||||
},
|
||||
setValue: {
|
||||
onSuccess() {},
|
||||
onError() {},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
A document resource is made up of multiple individual resources. In our running
|
||||
example, the resource object that fetches the document is at `todos.get`. So all
|
||||
the [properties of a resource](./Resource.story.md) are available on this
|
||||
object. Similarly, there are resources for `setValue`, and `delete`.
|
||||
|
||||
```js
|
||||
let todo = createDocumentResource({...})
|
||||
|
||||
todo.pg // pg returned from request
|
||||
todo.reload() // reload the pg
|
||||
|
||||
// update options
|
||||
todo.update({
|
||||
pagetype: '',
|
||||
name: ''
|
||||
})
|
||||
|
||||
todo.get // pg resource
|
||||
todos.get.loading // true when data is being fetched
|
||||
todos.get.error // error that occurred from making the request
|
||||
todos.get.promise // promise object of the request, can be awaited
|
||||
|
||||
// resource to set value(s) on the document
|
||||
todos.setValue
|
||||
todos.setValue.submit({
|
||||
// field value pairs to set
|
||||
status: 'Closed',
|
||||
description: 'Updated description'
|
||||
})
|
||||
|
||||
// same as setValue but debounced
|
||||
todos.setValueDebounced
|
||||
// will run once after 500ms
|
||||
todos.setValueDebounced.submit({
|
||||
description: 'Updated description'
|
||||
})
|
||||
|
||||
// resource to delete the document
|
||||
todos.delete
|
||||
todos.delete.submit()
|
||||
|
||||
// if whitelistedMethods is defined
|
||||
// you get a resource for each whitelisted method
|
||||
todos.sendEmail
|
||||
todos.sendEmail.submit
|
||||
todos.sendEmail.loading
|
||||
```
|
||||
234
docs/resources/List Resource.story.md
Normal file
234
docs/resources/List Resource.story.md
Normal file
@ -0,0 +1,234 @@
|
||||
# List Resource
|
||||
|
||||
List Resource is a wrapper on top of [Resource](./Resource.story.md) for working
|
||||
with lists. This feature only works with a Jingrow Framework backend as of now.
|
||||
|
||||
## Usage
|
||||
|
||||
A list resource knows how to fetch records of a PageType from a Jingrow Framework
|
||||
backend so there is no need to specify the url. Instead you only define
|
||||
`pagetype`, `fields`, `filters`, etc. You also get methods like `next()`,
|
||||
`setValue()`, etc.
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
class="flex items-center justify-between"
|
||||
v-for="todo in todos.data"
|
||||
:key="todo.name"
|
||||
>
|
||||
<div>
|
||||
{{ todo.description }}
|
||||
</div>
|
||||
<Badge>{{ todo.status }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Button @click="todos.next()"> Next Page </Button>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createListResource } from 'jingrow-ui'
|
||||
let todos = createListResource({
|
||||
pagetype: 'ToDo',
|
||||
fields: ['name', 'description', 'status'],
|
||||
orderBy: 'creation desc',
|
||||
start: 0,
|
||||
pageLength: 5,
|
||||
})
|
||||
todos.fetch()
|
||||
</script>
|
||||
```
|
||||
|
||||
## Options API
|
||||
|
||||
You can also define resources if you are using Options API. You need to register
|
||||
the `resourcesPlugin` first.
|
||||
|
||||
**main.js**
|
||||
|
||||
```js
|
||||
import { resourcesPlugin } from 'jingrow-ui'
|
||||
app.use(resourcesPlugin)
|
||||
```
|
||||
|
||||
In your .vue file, you can declare all your resources under the resources key as
|
||||
functions. The resource object will be available on `this.$resources.[name]`. In
|
||||
the following example, `this.$resources.todos` is the resource object.
|
||||
|
||||
**Component.vue**
|
||||
|
||||
```vue
|
||||
<script>
|
||||
export default {
|
||||
resources: {
|
||||
todos() {
|
||||
return {
|
||||
type: 'list',
|
||||
pagetype: 'ToDo',
|
||||
fields: ['name', 'description', 'status'],
|
||||
orderBy: 'creation desc',
|
||||
start: 0,
|
||||
pageLength: 5,
|
||||
auto: true,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## List of Options and API
|
||||
|
||||
Here is the list of all options and APIs that are available on a list resource.
|
||||
|
||||
### Options
|
||||
|
||||
```js
|
||||
let todos = createListResource({
|
||||
// name of the pagetype
|
||||
pagetype: 'ToDo',
|
||||
|
||||
// list of fields
|
||||
fields: ['name', 'description', 'status', ...],
|
||||
|
||||
// object of filters to apply
|
||||
filters: {
|
||||
status: 'Open'
|
||||
},
|
||||
|
||||
// the order in which records must be sorted
|
||||
orderBy: 'creation desc',
|
||||
|
||||
// index from which records should be fetched
|
||||
// default value is 0
|
||||
start: 0,
|
||||
|
||||
// number of records to fetch in a single request
|
||||
// default value is 20
|
||||
pageLength: 20,
|
||||
|
||||
// parent pagetype when you are fetching records of a child pagetype
|
||||
parent: null,
|
||||
|
||||
// set to 1 to enable debugging of list query
|
||||
debug: 0,
|
||||
|
||||
// cache key to cache the resource
|
||||
// can be a string
|
||||
cache: 'todos',
|
||||
// or an array that can be serialized
|
||||
cache: ['todos', 'faris@jingrow.com'],
|
||||
|
||||
// default value for url is "jingrow.client.get_list"
|
||||
// specify url if you want to use a custom API method
|
||||
url: 'todo_app.api.get_todos',
|
||||
|
||||
// make the first request automatically
|
||||
auto: true,
|
||||
|
||||
// events
|
||||
// error can occur from failed request
|
||||
onError(error) {
|
||||
|
||||
},
|
||||
// on successful response
|
||||
onSuccess(data) {
|
||||
|
||||
},
|
||||
// transform data before setting it
|
||||
transform(data) {
|
||||
for (let d of data) {
|
||||
d.open = false
|
||||
}
|
||||
return data
|
||||
},
|
||||
// other events
|
||||
fetchOne: {
|
||||
onSuccess() {},
|
||||
onError() {}
|
||||
},
|
||||
insert: {
|
||||
onSuccess() {},
|
||||
onError() {}
|
||||
},
|
||||
delete: {
|
||||
onSuccess() {},
|
||||
onError() {}
|
||||
},
|
||||
setValue: {
|
||||
onSuccess() {},
|
||||
onError() {}
|
||||
},
|
||||
runDocMethod: {
|
||||
onSuccess() {},
|
||||
onError() {}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
A list resource is made up of multiple individual resources. In our running
|
||||
example, the resource object that fetches the list is at `todos.list`. So all
|
||||
the [properties of a resource](./Resource.story.md) are available on this
|
||||
object. Similarly, there are resources for `fetchOne`, `setValue`, `insert`,
|
||||
`delete`, and `runDocMethod`.
|
||||
|
||||
```js
|
||||
let todos = createListResource({...})
|
||||
|
||||
todos.data // data returned from request
|
||||
todos.originalData // response data before being transformed
|
||||
todos.reload() // reload the existing list
|
||||
todos.next() // fetch the next page
|
||||
todos.hasNextPage // whether there is next page to fetch
|
||||
|
||||
// update list options
|
||||
todos.update({
|
||||
fields: ['*'],
|
||||
filters: {
|
||||
status: 'Closed'
|
||||
}
|
||||
})
|
||||
|
||||
todos.list // list resource
|
||||
todos.list.loading // true when data is being fetched
|
||||
todos.list.error // error that occurred from making the request
|
||||
todos.list.promise // promise object of the request, can be awaited
|
||||
|
||||
// resource to fetch and update a single record in the list
|
||||
todos.fetchOne
|
||||
// pass the name of the record to fetch that record and update the list
|
||||
todos.fetchOne.submit(name)
|
||||
|
||||
// resource to set value(s) for a single record in the list
|
||||
todos.setValue
|
||||
todos.setValue.submit({
|
||||
// id of the record
|
||||
name: '',
|
||||
// field value pairs to set
|
||||
status: 'Closed',
|
||||
description: 'Updated description'
|
||||
})
|
||||
|
||||
// resource to insert a new record in the list
|
||||
todos.insert
|
||||
todos.insert.submit({
|
||||
description: 'New todo'
|
||||
})
|
||||
|
||||
// resource to delete a single record
|
||||
todos.delete
|
||||
todos.delete.submit(name)
|
||||
|
||||
// resource to run a pg method
|
||||
todos.runDocMethod
|
||||
todos.runDocMethod.submit({
|
||||
// name of the pg method
|
||||
method: 'send_email',
|
||||
// name of the record
|
||||
name: '',
|
||||
// params to pass to the method
|
||||
email: 'test@example.com'
|
||||
})
|
||||
```
|
||||
293
docs/resources/Resource.story.md
Normal file
293
docs/resources/Resource.story.md
Normal file
@ -0,0 +1,293 @@
|
||||
# Resource
|
||||
|
||||
Resource is a feature to manage async data fetching and mutations in your Vue
|
||||
frontend. It will fetch, cache and keep data up-to-date from the server.
|
||||
|
||||
## Basic example
|
||||
|
||||
Any data that is fetched via a web request is called a resource in jingrow-ui
|
||||
terminology. When you are dealing with async data, you are also dealing with
|
||||
loading states, error states, refetching etc. In the traditional way of fetching
|
||||
data, you have to handle loading states, error states, and refetching yourself.
|
||||
|
||||
```js
|
||||
let data, loading, error
|
||||
try {
|
||||
data = await fetch('https://jsonplaceholder.typicode.com/posts/1')
|
||||
} catch (e) {
|
||||
error = e
|
||||
}
|
||||
// rest of your code
|
||||
```
|
||||
|
||||
The above example is still a very simplified version. You might also need a way
|
||||
to reload your data.
|
||||
|
||||
When you create a resource using the `createResource` function, it will create a
|
||||
reactive object with properties like `data`, `loading`, `error`, `reload()` etc.
|
||||
|
||||
The default request method is `POST`. This can be changed in the `options`
|
||||
object.
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Button @click="post.reload()" :loading="post.loading"> Reload </Button>
|
||||
<pre>{{ post }}</pre>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { createResource } from 'jingrow-ui'
|
||||
let post = createResource({
|
||||
url: 'https://jsonplaceholder.typicode.com/posts/1',
|
||||
method: 'GET',
|
||||
})
|
||||
post.fetch()
|
||||
</script>
|
||||
```
|
||||
|
||||
## Options API example
|
||||
|
||||
Resources can also be used in options API style. You need to register the
|
||||
`resourcesPlugin` first.
|
||||
|
||||
**main.js**
|
||||
|
||||
```js
|
||||
import { resourcesPlugin } from 'jingrow-ui'
|
||||
app.use(resourcesPlugin)
|
||||
```
|
||||
|
||||
In your `.vue` file, you can declare all your resources under the `resources`
|
||||
key as functions. The actual resource object will be available on
|
||||
`this.$resources.[name]`. In the following example, `this.$resources.posts` is
|
||||
the resource object.
|
||||
|
||||
**Component.vue**
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<pre>{{ $resources.posts }}</pre>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
resources: {
|
||||
posts() {
|
||||
return {
|
||||
url: 'https://jsonplaceholder.typicode.com/posts/1',
|
||||
// option to call .fetch() the first time automatically
|
||||
auto: true,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Caching example
|
||||
|
||||
Caching is a first-class feature in resources. To cache responses, just define a
|
||||
`cache` property in options with a unique global key. Now, the response will
|
||||
cached in memory as well as in IndexedDB. If you define another resource in a
|
||||
different part of your application with the same cache key, it will reuse the
|
||||
cached one.
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Button @click="post.reload()" :loading="post.loading">
|
||||
{{ post.fetched ? 'Reload' : 'Fetch data' }}
|
||||
</Button>
|
||||
<pre>{{ post.data }}</pre>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { createResource } from 'jingrow-ui'
|
||||
let post = createResource({
|
||||
url: 'https://jsonplaceholder.typicode.com/posts/1',
|
||||
cache: 'posts',
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## List of Options and API
|
||||
|
||||
Here is the list of all options and APIs that are available on a resource.
|
||||
|
||||
### Options
|
||||
|
||||
```js
|
||||
let post = createResource({
|
||||
// partial rest api routes
|
||||
url: '/api/posts/1'
|
||||
// or full urls
|
||||
url: 'https://jsonplaceholder.typicode.com/posts/1',
|
||||
|
||||
// http method: GET, POST, PUT, DELETE
|
||||
method: 'GET',
|
||||
|
||||
// parameters
|
||||
params: {
|
||||
id: 1
|
||||
},
|
||||
// generate params from function
|
||||
makeParams() {
|
||||
return {
|
||||
id: 1
|
||||
}
|
||||
},
|
||||
|
||||
// debounce request every 500ms
|
||||
debounce: 500,
|
||||
|
||||
// initial data
|
||||
initialData: []
|
||||
|
||||
// make the first request automatically
|
||||
auto: true,
|
||||
|
||||
// cache key to cache the resource
|
||||
// can be a string
|
||||
cache: 'post',
|
||||
// or an array that can be serialized
|
||||
cache: ['post', '1'],
|
||||
// you can also pass reactive variable here
|
||||
cache: ['post', postId]
|
||||
|
||||
// events
|
||||
// before making the request
|
||||
beforeSubmit(params) {
|
||||
|
||||
},
|
||||
// validate parameters before making request
|
||||
validate(params) {
|
||||
if (!params.id) {
|
||||
// return a string message to throw an error
|
||||
return 'id is required'
|
||||
}
|
||||
},
|
||||
// error can occur from failed request and validate function
|
||||
onError(error) {
|
||||
|
||||
},
|
||||
// on successful response
|
||||
onSuccess(data) {
|
||||
|
||||
},
|
||||
// transform data before setting it
|
||||
transform(data) {
|
||||
for (let d of data) {
|
||||
d.open = false
|
||||
}
|
||||
return data
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
```js
|
||||
let post = createResource({...})
|
||||
|
||||
post.data // data returned from request
|
||||
post.loading // true when data is being fetched
|
||||
post.error // error that occurred from making the request or from validate function
|
||||
post.promise // promise object of the request, can be awaited
|
||||
post.params // params that were sent for making the request, if using makeParams, the return value is set here
|
||||
post.fetched // true when data has been fetched once, stays true after that
|
||||
post.previousData // when you call .reload(), previousData is set to current data, and then data is set to new returned data
|
||||
|
||||
post.fetch() // make the web request (fetch call)
|
||||
post.reload() // alias to fetch
|
||||
post.submit() // alias to fetch
|
||||
|
||||
// you can also pass parameters while calling submit
|
||||
post.submit({ id: 2 })
|
||||
|
||||
// reset the state of this resource as a newly created one
|
||||
post.reset()
|
||||
|
||||
// update url and params
|
||||
post.update({
|
||||
url: '/api/users',
|
||||
params: {
|
||||
id: 2
|
||||
}
|
||||
})
|
||||
|
||||
// override data manually
|
||||
post.setData({
|
||||
id: 1,
|
||||
title: 'test'
|
||||
})
|
||||
// modify existing data
|
||||
post.setData(data => {
|
||||
return data.filter(d => d.open)
|
||||
})
|
||||
```
|
||||
|
||||
## Jingrow Resource
|
||||
|
||||
Fetching data from a Jingrow backend is no different from any other REST API
|
||||
service.
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Button @click="todos.reload()" :loading="todos.loading"> Reload </Button>
|
||||
<pre>{{ todos }}</pre>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { createResource } from 'jingrow-ui'
|
||||
let todos = createResource({
|
||||
url: '/api/action/jingrow.client.get_list',
|
||||
params: {
|
||||
pagetype: 'ToDo',
|
||||
filters: {
|
||||
allocated_to: 'faris@jingrow.com',
|
||||
},
|
||||
},
|
||||
})
|
||||
todos.fetch()
|
||||
</script>
|
||||
```
|
||||
|
||||
But the response format by Jingrow Framework requires some parsing to be done to
|
||||
extract data and errors. Since `jingrow-ui` is built primarily for Jingrow backend
|
||||
apps, we can make it understand Jingrow responses.
|
||||
|
||||
By default, resources use the `request` function exported from `jingrow-ui` which
|
||||
is a generic Fetch API wrapper. There is another function `jingrowRequest` which
|
||||
is a wrapper for Jingrow REST API calls. To make resources use it, you have to do
|
||||
the following:
|
||||
|
||||
**main.js**
|
||||
|
||||
```js
|
||||
import { setConfig, jingrowRequest } from 'jingrow-ui'
|
||||
setConfig('resourceFetcher', jingrowRequest)
|
||||
```
|
||||
|
||||
Now, resources will use `jingrowRequest` for making the web requests. You can
|
||||
also drop the `/api/method` part. The returned response will now set the data
|
||||
from `message` key and error from `exc`.
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Button @click="post.reload()" :loading="post.loading"> Reload </Button>
|
||||
<pre>{{ post }}</pre>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { createResource } from 'jingrow-ui'
|
||||
let todos = createResource({
|
||||
- url: '/api/action/jingrow.client.get_list',
|
||||
+ url: 'jingrow.client.get_list',
|
||||
params: {
|
||||
pagetype: 'ToDo',
|
||||
filters: {
|
||||
allocated_to: 'faris@jingrow.com',
|
||||
},
|
||||
},
|
||||
})
|
||||
todos.fetch()
|
||||
</script>
|
||||
```
|
||||
106
histoire.config.ts
Normal file
106
histoire.config.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { HstVue } from '@histoire/plugin-vue'
|
||||
import { defineConfig } from 'histoire'
|
||||
import resolveConfig from 'tailwindcss/resolveConfig'
|
||||
import tailwindConfig from './tailwind.config.js'
|
||||
|
||||
const fullConfig = resolveConfig(tailwindConfig)
|
||||
|
||||
export default defineConfig({
|
||||
setupFile: './histoire.setup.ts',
|
||||
plugins: [HstVue()],
|
||||
theme: {
|
||||
title: 'Jingrow UI',
|
||||
defaultColorScheme: 'light',
|
||||
hideColorSchemeSwitch: false,
|
||||
storeColorScheme: false,
|
||||
favicon: 'jingrow-ui-square.png',
|
||||
logo: {
|
||||
square: './jingrow-ui-square.png',
|
||||
light: './jingrow-ui.svg',
|
||||
dark: './jingrow-ui.svg',
|
||||
},
|
||||
colors: {
|
||||
gray: {
|
||||
50: '#f8f8f8',
|
||||
100: '#f3f3f3',
|
||||
200: '#ededed',
|
||||
300: '#e2e2e2',
|
||||
400: '#c7c7c7',
|
||||
500: '#999999',
|
||||
600: '#7c7c7c',
|
||||
700: 'rgb(23 23 23)',
|
||||
750: 'rgb(20 20 20)',
|
||||
800: '#383838',
|
||||
900: '#171717',
|
||||
},
|
||||
primary: {
|
||||
50: '#f8f8f8',
|
||||
100: '#f3f3f3',
|
||||
200: '#ededed',
|
||||
300: '#e2e2e2',
|
||||
400: '#c7c7c7',
|
||||
500: '#999999',
|
||||
600: '#7c7c7c',
|
||||
700: '#525252',
|
||||
800: '#383838',
|
||||
900: '#171717',
|
||||
},
|
||||
},
|
||||
},
|
||||
tree: {
|
||||
order(a, b) {
|
||||
let maintainOrder = [
|
||||
'Introduction',
|
||||
'Getting Started',
|
||||
'Resource',
|
||||
'List Resource',
|
||||
'Document Resource',
|
||||
'Utilities',
|
||||
'Directives',
|
||||
]
|
||||
let aIndex = maintainOrder.indexOf(a)
|
||||
let bIndex = maintainOrder.indexOf(b)
|
||||
if (aIndex > -1 && bIndex > -1) {
|
||||
return aIndex - bIndex
|
||||
} else if (aIndex > -1) {
|
||||
return -1
|
||||
} else if (bIndex > -1) {
|
||||
return 1
|
||||
} else {
|
||||
return a.localeCompare(b)
|
||||
}
|
||||
},
|
||||
groups: [
|
||||
{
|
||||
id: 'top',
|
||||
title: '',
|
||||
include: (file) => {
|
||||
return (
|
||||
file.path.includes('docs/') &&
|
||||
!file.path.includes('docs/resources/') &&
|
||||
!file.path.includes('docs/other/')
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'resources',
|
||||
title: 'Data Fetching',
|
||||
include: (file) => {
|
||||
return file.path.includes('docs/resources/')
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'components',
|
||||
title: 'Components',
|
||||
include: (file) => {
|
||||
return !file.path.includes('docs/')
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'other',
|
||||
title: 'Other',
|
||||
include: (file) => true,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
27
histoire.css
Normal file
27
histoire.css
Normal file
@ -0,0 +1,27 @@
|
||||
html,
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
@supports (font-variation-settings: normal) {
|
||||
html,
|
||||
body {
|
||||
font-family: 'InterVar', sans-serif;
|
||||
font-optical-sizing: auto;
|
||||
}
|
||||
}
|
||||
|
||||
input.htw-text-inherit {
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 100%;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.htw-prose a {
|
||||
text-decoration: underline;
|
||||
color: var(--tw-prose-code);
|
||||
}
|
||||
|
||||
.histoire-app-logo {
|
||||
width: 100px;
|
||||
}
|
||||
48
histoire.setup.ts
Normal file
48
histoire.setup.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import './histoire.css'
|
||||
import './src/style.css'
|
||||
|
||||
// development
|
||||
if (document.readyState == 'complete') {
|
||||
updateThemeAttrOnThemeChange()
|
||||
}
|
||||
|
||||
// production
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
updateThemeAttrOnThemeChange()
|
||||
})
|
||||
|
||||
function updateThemeAttrOnThemeChange() {
|
||||
const theme = document.documentElement.classList.contains('htw-dark')
|
||||
? 'htw-dark'
|
||||
: 'light'
|
||||
|
||||
updateTheme(theme)
|
||||
|
||||
let observer = new MutationObserver((mutations) => {
|
||||
for (const m of mutations) {
|
||||
const newValue = m.target.getAttribute(m.attributeName)
|
||||
updateTheme(newValue)
|
||||
}
|
||||
})
|
||||
// observe changes to the class attribute on root element
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeOldValue: true,
|
||||
attributeFilter: ['class'],
|
||||
})
|
||||
}
|
||||
|
||||
function updateTheme(value: string) {
|
||||
if (value === 'htw-dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark')
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-theme', 'light')
|
||||
}
|
||||
}
|
||||
|
||||
// handle route param in url
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const route = urlParams.get('route')
|
||||
if (route) {
|
||||
history.pushState({}, '', route)
|
||||
}
|
||||
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Jingrow UI</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
jingrow-ui-1.1.202.tgz
Normal file
BIN
jingrow-ui-1.1.202.tgz
Normal file
Binary file not shown.
BIN
jingrow-ui-square.png
Normal file
BIN
jingrow-ui-square.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
jingrow-ui.png
Normal file
BIN
jingrow-ui.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
1
jingrow-ui.svg
Normal file
1
jingrow-ui.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 17 KiB |
53
jingrow/Billing/SignupBanner.vue
Normal file
53
jingrow/Billing/SignupBanner.vue
Normal file
@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="!isSidebarCollapsed"
|
||||
class="flex flex-col gap-3 shadow-sm rounded-lg py-2.5 px-3 bg-surface-white text-base"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<slot>
|
||||
<div class="inline-flex gap-2 items-center font-medium">
|
||||
<FeatherIcon class="h-4" name="info" />
|
||||
Loved the demo?
|
||||
</div>
|
||||
<div class="text-ink-gray-7 text-p-sm">
|
||||
{{ `Try ${appName} for free with a 14-day trial.` }}
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
<Button label="Sign up now" theme="blue" @click="signupNow">
|
||||
<template #prefix>
|
||||
<LightningIcon class="size-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<Button v-else @click="signupNow">
|
||||
<LightningIcon class="h-4 my-0.5 shrink-0" />
|
||||
</Button>
|
||||
</template>
|
||||
<script setup>
|
||||
import LightningIcon from '../Icons/LightningIcon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
isSidebarCollapsed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
appName: {
|
||||
type: String,
|
||||
default: 'Jingrow CRM',
|
||||
},
|
||||
redirectURL: {
|
||||
type: String,
|
||||
default: 'https://jcloud.jingrow.com/crm/signup',
|
||||
},
|
||||
afterSignup: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
})
|
||||
|
||||
function signupNow() {
|
||||
window.open(props.redirectURL, '_blank')
|
||||
props.afterSignup?.()
|
||||
}
|
||||
</script>
|
||||
85
jingrow/Billing/TrialBanner.vue
Normal file
85
jingrow/Billing/TrialBanner.vue
Normal file
@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="!isSidebarCollapsed && showBanner"
|
||||
class="flex flex-col gap-3 shadow-sm rounded-lg py-2.5 px-3 bg-surface-modal text-base"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="inline-flex text-ink-gray-9 gap-2 items-center font-medium">
|
||||
<FeatherIcon class="h-4" name="info" />
|
||||
{{ trialTitle }}
|
||||
</div>
|
||||
<div class="text-ink-gray-7 text-p-sm">
|
||||
{{ trialMessage }}
|
||||
</div>
|
||||
</div>
|
||||
<Button :label="'Upgrade plan'" theme="blue" @click="upgradePlan">
|
||||
<template #prefix>
|
||||
<LightningIcon class="size-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<Button v-else-if="isSidebarCollapsed && showBanner" @click="upgradePlan">
|
||||
<LightningIcon class="h-4 my-0.5 shrink-0" />
|
||||
</Button>
|
||||
</template>
|
||||
<script setup>
|
||||
import LightningIcon from '../Icons/LightningIcon.vue'
|
||||
import FeatherIcon from '../../src/components/FeatherIcon.vue'
|
||||
import { Button } from '../../src/components/Button'
|
||||
import { createResource } from '../../src/resources'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
isSidebarCollapsed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
afterUpgrade: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
})
|
||||
|
||||
const trialEndDays = ref(0)
|
||||
const showBanner = ref(false)
|
||||
const baseEndpoint = ref('https://jcloud.jingrow.com')
|
||||
const siteName = ref('')
|
||||
|
||||
const trialTitle = computed(() => {
|
||||
return trialEndDays.value > 1
|
||||
? 'Trial ends in ' + trialEndDays.value + ' days'
|
||||
: 'Trial ends tomorrow'
|
||||
})
|
||||
|
||||
const trialMessage = 'Upgrade to a paid plan for uninterrupted services'
|
||||
|
||||
createResource({
|
||||
url: 'jingrow.integrations.jingrow_providers.jingrowcloud_billing.current_site_info',
|
||||
cache: 'current_site_info_data',
|
||||
auto: true,
|
||||
onSuccess: (data) => {
|
||||
trialEndDays.value = calculateTrialEndDays(data.trial_end_date)
|
||||
baseEndpoint.value = data.base_url
|
||||
siteName.value = data.site_name
|
||||
showBanner.value = data.plan.is_trial_plan && trialEndDays.value > 0
|
||||
},
|
||||
})
|
||||
|
||||
function calculateTrialEndDays(trialEndDate) {
|
||||
if (!trialEndDate) return 0
|
||||
|
||||
trialEndDate = new Date(trialEndDate)
|
||||
const today = new Date()
|
||||
const diffTime = trialEndDate - today
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
return diffDays
|
||||
}
|
||||
|
||||
function upgradePlan() {
|
||||
window.open(
|
||||
`${baseEndpoint.value}/dashboard/sites/${siteName.value}`,
|
||||
'_blank',
|
||||
)
|
||||
props.afterUpgrade?.()
|
||||
}
|
||||
</script>
|
||||
166
jingrow/Help/HelpModal.vue
Normal file
166
jingrow/Help/HelpModal.vue
Normal file
@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<div
|
||||
v-show="show"
|
||||
class="fixed z-50 right-0 w-80 h-[calc(100%_-_80px)] text-ink-gray-9 m-5 mt-[62px] p-3 flex gap-2 flex-col justify-between rounded-lg bg-surface-modal shadow-2xl"
|
||||
:class="{ 'top-[calc(100%_-_120px)] border': minimize }"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex items-center justify-between px-2 py-1.5">
|
||||
<div class="text-base font-medium">
|
||||
{{ headingTitle }}
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<Dropdown v-if="options.length" :options="options">
|
||||
<Button variant="ghost" icon="more-horizontal" />
|
||||
</Dropdown>
|
||||
<Button @click="minimize = !minimize" variant="ghost">
|
||||
<component
|
||||
:is="minimize ? MaximizeIcon : MinimizeIcon"
|
||||
class="h-3.5"
|
||||
/>
|
||||
</Button>
|
||||
<Button variant="ghost" @click="show = false">
|
||||
<FeatherIcon name="x" class="h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-full overflow-hidden flex flex-col">
|
||||
<OnboardingSteps
|
||||
v-if="!isOnboardingStepsCompleted && !showHelpCenter"
|
||||
:title="title"
|
||||
:logo="logo"
|
||||
:afterSkip="afterSkip"
|
||||
:afterSkipAll="afterSkipAll"
|
||||
:afterReset="afterReset"
|
||||
:afterResetAll="afterResetAll"
|
||||
:appName="appName"
|
||||
/>
|
||||
<HelpCenter
|
||||
v-else-if="showHelpCenter"
|
||||
v-model="articles"
|
||||
:docsLink="docsLink"
|
||||
/>
|
||||
</div>
|
||||
<div v-for="item in footerItems" class="flex flex-col gap-1.5">
|
||||
<div
|
||||
class="w-full flex gap-2 items-center hover:bg-surface-gray-1 text-ink-gray-8 rounded px-2 py-1.5 cursor-pointer"
|
||||
@click="item.onClick"
|
||||
>
|
||||
<component :is="item.icon" class="h-4" />
|
||||
<div class="text-base">{{ item.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import Dropdown from '../../src/components/Dropdown/Dropdown.vue'
|
||||
import Button from '../../src/components/Button/Button.vue'
|
||||
import StepsIcon from '../Icons/StepsIcon.vue'
|
||||
import MinimizeIcon from '../Icons/MinimizeIcon.vue'
|
||||
import MaximizeIcon from '../Icons/MaximizeIcon.vue'
|
||||
import HelpIcon from '../Icons/HelpIcon.vue'
|
||||
import OnboardingSteps from '../Onboarding/OnboardingSteps.vue'
|
||||
import HelpCenter from '../HelpCenter/HelpCenter.vue'
|
||||
import { useOnboarding } from '../Onboarding/onboarding'
|
||||
import { showHelpCenter } from '../HelpCenter/helpCenter'
|
||||
import { minimize } from '../Help/help'
|
||||
import { onMounted, computed } from 'vue'
|
||||
import FeatherIcon from '../../src/components/FeatherIcon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
appName: {
|
||||
type: String,
|
||||
default: 'jingrowcrm',
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Jingrow CRM',
|
||||
},
|
||||
logo: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
afterSkip: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
afterSkipAll: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
afterReset: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
afterResetAll: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
docsLink: {
|
||||
type: String,
|
||||
default: 'https://docs.jingrow.com/crm',
|
||||
},
|
||||
})
|
||||
|
||||
const { syncStatus, resetAll, isOnboardingStepsCompleted } = useOnboarding(
|
||||
props.appName,
|
||||
)
|
||||
|
||||
const show = defineModel()
|
||||
const articles = defineModel('articles')
|
||||
|
||||
const headingTitle = computed(() => {
|
||||
if (!isOnboardingStepsCompleted.value && !showHelpCenter.value) {
|
||||
return 'Getting started'
|
||||
} else if (showHelpCenter.value) {
|
||||
return 'Help center'
|
||||
}
|
||||
})
|
||||
|
||||
const options = computed(() => {
|
||||
let items = [
|
||||
{
|
||||
icon: StepsIcon,
|
||||
label: 'Reset onboarding steps',
|
||||
onClick: resetOnboardingSteps,
|
||||
condition: () => showHelpCenter.value && isOnboardingStepsCompleted.value,
|
||||
},
|
||||
]
|
||||
|
||||
return items.filter((item) => item.condition())
|
||||
})
|
||||
|
||||
const footerItems = computed(() => {
|
||||
let items = [
|
||||
{
|
||||
icon: HelpIcon,
|
||||
label: 'Help centre',
|
||||
onClick: () => {
|
||||
syncStatus()
|
||||
showHelpCenter.value = true
|
||||
},
|
||||
condition: !isOnboardingStepsCompleted.value && !showHelpCenter.value,
|
||||
},
|
||||
{
|
||||
icon: StepsIcon,
|
||||
label: 'Getting started',
|
||||
onClick: () => (showHelpCenter.value = false),
|
||||
condition: showHelpCenter.value && !isOnboardingStepsCompleted.value,
|
||||
},
|
||||
]
|
||||
|
||||
return items.filter((item) => item.condition)
|
||||
})
|
||||
|
||||
function resetOnboardingSteps() {
|
||||
resetAll()
|
||||
isOnboardingStepsCompleted.value = false
|
||||
showHelpCenter.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isOnboardingStepsCompleted.value) {
|
||||
showHelpCenter.value = true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
4
jingrow/Help/help.js
Normal file
4
jingrow/Help/help.js
Normal file
@ -0,0 +1,4 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const showHelpModal = ref(false)
|
||||
export const minimize = ref(false)
|
||||
114
jingrow/HelpCenter/HelpCenter.vue
Normal file
114
jingrow/HelpCenter/HelpCenter.vue
Normal file
@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 overflow-hidden">
|
||||
<div class="m-1">
|
||||
<TextInput
|
||||
ref="searchInput"
|
||||
:placeholder="'Search articles...'"
|
||||
v-model="search"
|
||||
:debounce="300"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon name="search" class="h-4 text-ink-gray-5" />
|
||||
</template>
|
||||
</TextInput>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between items-center text-base text-ink-gray-5 mx-2"
|
||||
>
|
||||
<div>All articles</div>
|
||||
<Button variant="ghost" @click="openDocs">
|
||||
<FeatherIcon name="arrow-up-right" class="h-4 text-ink-gray-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5 overflow-y-auto">
|
||||
<div
|
||||
v-for="a in parsedArticles"
|
||||
:key="a.title"
|
||||
class="flex flex-col gap-1.5"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between p-1.5 hover:bg-surface-gray-1 rounded cursor-pointer"
|
||||
@click="a.opened = !a.opened"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<FeatherIcon
|
||||
:name="a.opened ? 'chevron-down' : 'chevron-right'"
|
||||
class="h-4 text-ink-gray-5"
|
||||
/>
|
||||
<div class="text-base text-ink-gray-8">{{ a.title }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="a.opened" class="flex flex-col gap-1.5 ml-5">
|
||||
<div
|
||||
v-for="subArticle in a.subArticles"
|
||||
:key="subArticle.name"
|
||||
class="group flex items-center justify-between gap-2 p-1.5 hover:bg-surface-gray-1 rounded cursor-pointer"
|
||||
@click="() => openDoc(subArticle.name)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<FeatherIcon name="file-text" class="h-4 text-ink-gray-5" />
|
||||
<div class="text-base text-ink-gray-8">
|
||||
{{ subArticle.title }}
|
||||
</div>
|
||||
</div>
|
||||
<FeatherIcon
|
||||
name="arrow-up-right"
|
||||
class="h-4 hidden group-hover:flex text-ink-gray-5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import Button from '../../src/components/Button/Button.vue'
|
||||
import FeatherIcon from '../../src/components/FeatherIcon.vue'
|
||||
import TextInput from '../../src/components/TextInput/TextInput.vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
docsLink: {
|
||||
type: String,
|
||||
default: 'https://docs.jingrow.com/crm',
|
||||
},
|
||||
})
|
||||
|
||||
const searchInput = ref(null)
|
||||
const search = ref('')
|
||||
const articles = defineModel()
|
||||
|
||||
const parsedArticles = computed(() => {
|
||||
if (!search.value) return articles.value
|
||||
|
||||
return articles.value.filter((a) => {
|
||||
const filteredSubArticles = a.subArticles.filter((subArticle) => {
|
||||
return subArticle.title.toLowerCase().includes(search.value.toLowerCase())
|
||||
})
|
||||
|
||||
if (
|
||||
a.title.toLowerCase().includes(search.value.toLowerCase()) ||
|
||||
filteredSubArticles.length > 0
|
||||
) {
|
||||
return {
|
||||
...a,
|
||||
subArticles: filteredSubArticles,
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
})
|
||||
|
||||
function openDocs() {
|
||||
window.open(props.docsLink, '_blank')
|
||||
}
|
||||
|
||||
function openDoc(name) {
|
||||
window.open(`${props.docsLink}/${name}`, '_blank')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
searchInput.value?.el?.focus()
|
||||
})
|
||||
</script>
|
||||
3
jingrow/HelpCenter/helpCenter.js
Normal file
3
jingrow/HelpCenter/helpCenter.js
Normal file
@ -0,0 +1,3 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const showHelpCenter = ref(false)
|
||||
16
jingrow/Icons/HelpIcon.vue
Normal file
16
jingrow/Icons/HelpIcon.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M14.25 8C14.25 11.4518 11.4518 14.25 8 14.25C4.54822 14.25 1.75 11.4518 1.75 8C1.75 4.54822 4.54822 1.75 8 1.75C11.4518 1.75 14.25 4.54822 14.25 8ZM15.25 8C15.25 12.0041 12.0041 15.25 8 15.25C3.99594 15.25 0.75 12.0041 0.75 8C0.75 3.99594 3.99594 0.75 8 0.75C12.0041 0.75 15.25 3.99594 15.25 8ZM7.37988 9.37695V9.44531H8.39062V9.37695C8.39062 9.10352 8.41992 8.88542 8.47852 8.72266C8.53711 8.55664 8.62826 8.41504 8.75195 8.29785C8.87891 8.18066 9.04329 8.06185 9.24512 7.94141C9.56087 7.74609 9.8099 7.51009 9.99219 7.2334C10.1745 6.95345 10.2656 6.61328 10.2656 6.21289C10.2656 5.82878 10.1745 5.49186 9.99219 5.20215C9.81315 4.91244 9.56087 4.6862 9.23535 4.52344C8.90983 4.36068 8.52734 4.2793 8.08789 4.2793C7.69401 4.2793 7.33268 4.35579 7.00391 4.50879C6.67513 4.65853 6.41146 4.88151 6.21289 5.17773C6.01432 5.4707 5.90853 5.82878 5.89551 6.25195H6.96973C6.986 6.0013 7.04948 5.79785 7.16016 5.6416C7.27083 5.4821 7.40755 5.36491 7.57031 5.29004C7.73633 5.21517 7.90885 5.17773 8.08789 5.17773C8.28971 5.17773 8.47363 5.22005 8.63965 5.30469C8.80892 5.38932 8.94075 5.50814 9.03516 5.66113C9.13281 5.81413 9.18164 5.99479 9.18164 6.20312C9.18164 6.47005 9.11003 6.69954 8.9668 6.8916C8.82357 7.0804 8.64453 7.23828 8.42969 7.36523C8.21159 7.50195 8.02279 7.64193 7.86328 7.78516C7.70703 7.92513 7.58659 8.11556 7.50195 8.35645C7.42057 8.59408 7.37988 8.93425 7.37988 9.37695ZM7.37988 11.5205C7.51986 11.654 7.69076 11.7207 7.89258 11.7207C8.09766 11.7207 8.26855 11.654 8.40527 11.5205C8.54525 11.3838 8.61523 11.2161 8.61523 11.0176C8.61523 10.8158 8.54525 10.6481 8.40527 10.5146C8.26855 10.3779 8.09766 10.3096 7.89258 10.3096C7.69076 10.3096 7.51986 10.3779 7.37988 10.5146C7.24316 10.6481 7.1748 10.8158 7.1748 11.0176C7.1748 11.2161 7.24316 11.3838 7.37988 11.5205Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
16
jingrow/Icons/LightningIcon.vue
Normal file
16
jingrow/Icons/LightningIcon.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg
|
||||
width="17"
|
||||
height="16"
|
||||
viewBox="0 0 17 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M6.2641 1C5.5758 1 4.97583 1.46845 4.80889 2.1362L3.57555 7.06953C3.33887 8.01625 4.05491 8.93333 5.03077 8.93333H7.50682L6.72168 14.4293C6.68838 14.6624 6.82229 14.8872 7.04319 14.9689C7.26408 15.0507 7.51204 14.9671 7.63849 14.7684L13.2161 6.00354C13.6398 5.33782 13.1616 4.46667 12.3725 4.46667H9.59038L10.3017 1.62127C10.3391 1.4719 10.3055 1.31365 10.2108 1.19229C10.116 1.07094 9.97063 1 9.81666 1H6.2641ZM5.77903 2.37873C5.83468 2.15615 6.03467 2 6.2641 2H9.17627L8.46492 4.8454C8.42758 4.99477 8.46114 5.15302 8.55589 5.27437C8.65064 5.39573 8.79602 5.46667 8.94999 5.46667H12.3725L8.0395 12.2757L8.5783 8.50404C8.5988 8.36056 8.55602 8.21523 8.46105 8.10573C8.36608 7.99623 8.22827 7.93333 8.08332 7.93333H5.03077C4.70548 7.93333 4.4668 7.62764 4.5457 7.31207L5.77903 2.37873Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
19
jingrow/Icons/MaximizeIcon.vue
Normal file
19
jingrow/Icons/MaximizeIcon.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="feather feather-maximize-2"
|
||||
>
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<polyline points="9 21 3 21 3 15" />
|
||||
<line x1="21" y1="3" x2="14" y2="10" />
|
||||
<line x1="3" y1="21" x2="10" y2="14" />
|
||||
</svg>
|
||||
</template>
|
||||
19
jingrow/Icons/MinimizeIcon.vue
Normal file
19
jingrow/Icons/MinimizeIcon.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="feather feather-minimize-2"
|
||||
>
|
||||
<polyline points="4 14 10 14 10 20" />
|
||||
<polyline points="20 10 14 10 14 4" />
|
||||
<line x1="14" y1="10" x2="21" y2="3" />
|
||||
<line x1="3" y1="21" x2="10" y2="14" />
|
||||
</svg>
|
||||
</template>
|
||||
16
jingrow/Icons/StepsIcon.vue
Normal file
16
jingrow/Icons/StepsIcon.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M8 6.5C9.10457 6.5 10 5.60457 10 4.5C10 3.39543 9.10457 2.5 8 2.5C6.89543 2.5 6 3.39543 6 4.5C6 5.60457 6.89543 6.5 8 6.5ZM8 7.5C9.65685 7.5 11 6.15685 11 4.5C11 2.84315 9.65685 1.5 8 1.5C6.34315 1.5 5 2.84315 5 4.5C5 6.15685 6.34315 7.5 8 7.5ZM12 13.5C13.1046 13.5 14 12.6046 14 11.5C14 10.3954 13.1046 9.5 12 9.5C10.8954 9.5 10 10.3954 10 11.5C10 12.6046 10.8954 13.5 12 13.5ZM12 14.5C13.6569 14.5 15 13.1569 15 11.5C15 9.84315 13.6569 8.5 12 8.5C10.3431 8.5 9 9.84315 9 11.5C9 13.1569 10.3431 14.5 12 14.5ZM6 11.5C6 12.6046 5.10457 13.5 4 13.5C2.89543 13.5 2 12.6046 2 11.5C2 10.3954 2.89543 9.5 4 9.5C5.10457 9.5 6 10.3954 6 11.5ZM7 11.5C7 13.1569 5.65685 14.5 4 14.5C2.34315 14.5 1 13.1569 1 11.5C1 9.84315 2.34315 8.5 4 8.5C5.65685 8.5 7 9.84315 7 11.5Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
84
jingrow/Onboarding/GettingStartedBanner.vue
Normal file
84
jingrow/Onboarding/GettingStartedBanner.vue
Normal file
@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="!isSidebarCollapsed"
|
||||
class="flex flex-col gap-3 shadow-sm rounded-lg py-2.5 px-3 bg-surface-modal text-base"
|
||||
>
|
||||
<div
|
||||
v-if="stepsCompleted != totalSteps"
|
||||
class="inline-flex text-ink-gray-9 gap-2"
|
||||
>
|
||||
<StepsIcon class="h-4 my-0.5 shrink-0" />
|
||||
<div class="flex flex-col text-p-sm gap-0.5">
|
||||
<div class="font-medium">
|
||||
{{ 'Getting started' }}
|
||||
</div>
|
||||
<div class="text-ink-gray-7">
|
||||
{{ `${stepsCompleted}/${totalSteps} steps` }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-1">
|
||||
<div class="flex items-center justify-between gap-1">
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<StepsIcon class="h-4 my-0.5" />
|
||||
<div class="text-ink-gray-9 font-medium">
|
||||
{{ 'You are all set' }}
|
||||
</div>
|
||||
</div>
|
||||
<FeatherIcon
|
||||
name="x"
|
||||
class="h-4 cursor-pointer"
|
||||
@click="
|
||||
() => {
|
||||
showHelpCenter = true
|
||||
isOnboardingStepsCompleted = true
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-p-sm text-ink-gray-7">
|
||||
{{ 'All steps are completed successfully' }}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="stepsCompleted != totalSteps"
|
||||
:label="stepsCompleted == 0 ? 'Start now' : 'Continue'"
|
||||
theme="blue"
|
||||
@click="openOnboarding"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon name="chevrons-right" class="size-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<Button v-else-if="stepsCompleted != totalSteps" @click="openOnboarding">
|
||||
<StepsIcon class="h-4 my-0.5 shrink-0" />
|
||||
</Button>
|
||||
</template>
|
||||
<script setup>
|
||||
import StepsIcon from '../Icons/StepsIcon.vue'
|
||||
import Button from '../../src/components/Button/Button.vue'
|
||||
import FeatherIcon from '../../src/components/FeatherIcon.vue'
|
||||
import { useOnboarding } from './onboarding'
|
||||
import { showHelpCenter } from '../HelpCenter/helpCenter'
|
||||
import { showHelpModal, minimize } from '../Help/help'
|
||||
|
||||
const props = defineProps({
|
||||
isSidebarCollapsed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
appName: {
|
||||
type: String,
|
||||
default: 'jingrowcrm',
|
||||
},
|
||||
})
|
||||
|
||||
const { stepsCompleted, totalSteps, isOnboardingStepsCompleted } =
|
||||
useOnboarding(props.appName)
|
||||
|
||||
const openOnboarding = () => {
|
||||
minimize.value = false
|
||||
showHelpModal.value = true
|
||||
}
|
||||
</script>
|
||||
68
jingrow/Onboarding/IntermediateStepModal.vue
Normal file
68
jingrow/Onboarding/IntermediateStepModal.vue
Normal file
@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<Dialog v-model="show" :options="options">
|
||||
<template #boday-header>
|
||||
<slot name="body-header"></slot>
|
||||
</template>
|
||||
<template #body-content>
|
||||
<slot>
|
||||
<div class="flex flex-col gap-2 text-ink-gray-9 text-base">
|
||||
<div v-if="currentStep.message">{{ currentStep.message }}</div>
|
||||
<video
|
||||
v-if="currentStep.videoURL"
|
||||
class="w-full rounded"
|
||||
controls
|
||||
autoplay
|
||||
muted
|
||||
>
|
||||
<source :src="currentStep.videoURL" type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
</slot>
|
||||
</template>
|
||||
<template #actions>
|
||||
<slot name="actions"></slot>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog } from '../../src/components/Dialog'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
currentStep: {
|
||||
type: Object,
|
||||
default: {
|
||||
title: 'Title',
|
||||
message: 'Message',
|
||||
videoURL: '',
|
||||
buttonLabel: 'Button Label',
|
||||
onClick: () => {},
|
||||
},
|
||||
},
|
||||
dialogOptions: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
|
||||
const show = defineModel()
|
||||
|
||||
const options = computed(() => {
|
||||
if (props.dialogOptions && Object.keys(props.dialogOptions).length) {
|
||||
return props.dialogOptions
|
||||
}
|
||||
|
||||
return {
|
||||
title: props.currentStep.title,
|
||||
size: '2xl',
|
||||
actions: [
|
||||
{
|
||||
label: props.currentStep.buttonLabel,
|
||||
variant: 'solid',
|
||||
onClick: props.currentStep.onClick,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
</script>
|
||||
145
jingrow/Onboarding/OnboardingSteps.vue
Normal file
145
jingrow/Onboarding/OnboardingSteps.vue
Normal file
@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div class="flex flex-col justify-center items-center gap-1 mt-4 mb-7">
|
||||
<component :is="logo" class="size-10 shrink-0 rounded mb-4" />
|
||||
<div class="text-base font-medium">
|
||||
{{ 'Welcome to ' + title }}
|
||||
</div>
|
||||
<div class="text-p-base font-normal">
|
||||
{{ `${stepsCompleted}/${totalSteps} steps completed` }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2.5 overflow-hidden">
|
||||
<div class="flex justify-between items-center py-0.5">
|
||||
<Badge
|
||||
:label="`${completedPercentage}% completed`"
|
||||
:theme="completedPercentage == 100 ? 'green' : 'orange'"
|
||||
size="lg"
|
||||
/>
|
||||
<div class="flex">
|
||||
<Button
|
||||
v-if="completedPercentage != 0"
|
||||
variant="ghost"
|
||||
:label="'Reset all'"
|
||||
@click="() => resetAll(afterResetAll)"
|
||||
/>
|
||||
<Button
|
||||
v-if="completedPercentage != 100"
|
||||
variant="ghost"
|
||||
:label="'Skip all'"
|
||||
@click="() => skipAll(afterSkipAll)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5 overflow-y-auto">
|
||||
<div
|
||||
v-for="step in steps"
|
||||
:key="step.title"
|
||||
class="group w-full flex gap-2 justify-between items-center hover:bg-surface-gray-1 rounded px-2 py-1.5 cursor-pointer"
|
||||
@click.stop="
|
||||
() => !step.completed && !isDependent(step) && step.onClick()
|
||||
"
|
||||
>
|
||||
<component
|
||||
:is="isDependent(step) ? Tooltip : 'div'"
|
||||
:text="dependsOnTooltip(step)"
|
||||
>
|
||||
<div
|
||||
class="flex gap-2 items-center"
|
||||
:class="[
|
||||
step.completed
|
||||
? 'text-ink-gray-5'
|
||||
: isDependent(step)
|
||||
? 'text-ink-gray-4'
|
||||
: 'text-ink-gray-8',
|
||||
]"
|
||||
>
|
||||
<component :is="step.icon" class="h-4" />
|
||||
<div class="text-base" :class="{ 'line-through': step.completed }">
|
||||
{{ step.title }}
|
||||
</div>
|
||||
</div>
|
||||
</component>
|
||||
<Button
|
||||
v-if="!step.completed && !isDependent(step)"
|
||||
:label="'Skip'"
|
||||
class="!h-4 text-xs !text-ink-gray-6 hidden group-hover:flex"
|
||||
@click="() => skip(step.name, afterSkip)"
|
||||
/>
|
||||
<Button
|
||||
v-else-if="!isDependent(step)"
|
||||
:label="'Reset'"
|
||||
class="!h-4 text-xs !text-ink-gray-6 hidden group-hover:flex"
|
||||
@click.stop="() => reset(step.name, afterReset)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { useOnboarding } from './onboarding'
|
||||
import Tooltip from '../../src/components/Tooltip/Tooltip.vue'
|
||||
import Button from '../../src/components/Button/Button.vue'
|
||||
import Badge from '../../src/components/Badge/Badge.vue'
|
||||
|
||||
const props = defineProps({
|
||||
appName: {
|
||||
type: String,
|
||||
default: 'jingrowcrm',
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Jingrow CRM',
|
||||
},
|
||||
logo: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
afterSkip: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
afterSkipAll: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
afterReset: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
afterResetAll: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
})
|
||||
|
||||
function isDependent(step) {
|
||||
if (step.dependsOn && !step.completed) {
|
||||
const dependsOnStep = steps.find((s) => s.name === step.dependsOn)
|
||||
if (dependsOnStep && !dependsOnStep.completed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function dependsOnTooltip(step) {
|
||||
if (step.dependsOn && !step.completed) {
|
||||
const dependsOnStep = steps.find((s) => s.name === step.dependsOn)
|
||||
if (dependsOnStep && !dependsOnStep.completed) {
|
||||
return `You need to complete "${dependsOnStep.title}" first.`
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const {
|
||||
steps,
|
||||
stepsCompleted,
|
||||
totalSteps,
|
||||
completedPercentage,
|
||||
skip,
|
||||
skipAll,
|
||||
reset,
|
||||
resetAll,
|
||||
} = useOnboarding(props.appName)
|
||||
</script>
|
||||
165
jingrow/Onboarding/onboarding.js
Normal file
165
jingrow/Onboarding/onboarding.js
Normal file
@ -0,0 +1,165 @@
|
||||
import call from '../../src/utils/call'
|
||||
import { createResource } from '../../src/resources'
|
||||
import { minimize, showHelpModal } from '../Help/help'
|
||||
import { sessionUser } from '../session'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { computed, reactive } from 'vue'
|
||||
|
||||
const onboardings = reactive({})
|
||||
const onboardingStatus = useStorage('onboardingStatus', {})
|
||||
|
||||
export function useOnboarding(appName) {
|
||||
const user = sessionUser()
|
||||
|
||||
if (!user || user === 'Guest') return
|
||||
|
||||
const isOnboardingStepsCompleted = useStorage(
|
||||
'isOnboardingStepsCompleted' + appName + user,
|
||||
false,
|
||||
)
|
||||
|
||||
const onboardingSteps = computed(
|
||||
() =>
|
||||
onboardingStatus.value?.[user]?.[appName + '_onboarding_status'] || [],
|
||||
)
|
||||
|
||||
if (!onboardingSteps.value.length && !isOnboardingStepsCompleted.value) {
|
||||
createResource({
|
||||
url: 'jingrow.onboarding.get_onboarding_status',
|
||||
cache: 'onboarding_status',
|
||||
auto: true,
|
||||
onSuccess: (data) => {
|
||||
onboardingStatus.value[user] = data
|
||||
syncStatus()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const stepsCompleted = computed(
|
||||
() => onboardings[appName]?.filter((step) => step.completed).length || 0,
|
||||
)
|
||||
const totalSteps = computed(() => onboardings[appName]?.length || 0)
|
||||
|
||||
const completedPercentage = computed(() =>
|
||||
Math.floor((stepsCompleted.value / totalSteps.value) * 100),
|
||||
)
|
||||
|
||||
function skip(step, callback = null) {
|
||||
updateOnboardingStep(step, true, true, callback)
|
||||
}
|
||||
|
||||
function skipAll(callback = null) {
|
||||
updateAll(true, callback)
|
||||
}
|
||||
|
||||
function reset(step, callback = null) {
|
||||
updateOnboardingStep(step, false, false, callback)
|
||||
}
|
||||
|
||||
function resetAll(callback = null) {
|
||||
updateAll(false, callback)
|
||||
}
|
||||
|
||||
function updateOnboardingStep(
|
||||
step,
|
||||
value = true,
|
||||
skipped = false,
|
||||
callback = null,
|
||||
) {
|
||||
if (isOnboardingStepsCompleted.value) return
|
||||
|
||||
if (!onboardingSteps.value.length) {
|
||||
if (!onboardingStatus.value[user]) {
|
||||
onboardingStatus.value[user] = {}
|
||||
}
|
||||
onboardingStatus.value[user][appName + '_onboarding_status'] =
|
||||
onboardings[appName].map((s) => {
|
||||
return { name: s.name, completed: false }
|
||||
})
|
||||
}
|
||||
|
||||
let index = onboardingSteps.value.findIndex((s) => s.name === step)
|
||||
if (index !== -1) {
|
||||
onboardingSteps.value[index].completed = value
|
||||
onboardings[appName][index].completed = value
|
||||
}
|
||||
|
||||
updateUserOnboardingStatus(onboardingSteps.value)
|
||||
|
||||
callback?.(step, skipped)
|
||||
|
||||
minimize.value = false
|
||||
}
|
||||
|
||||
function updateAll(value, callback = null) {
|
||||
if (isOnboardingStepsCompleted.value && value) return
|
||||
|
||||
if (!onboardingSteps.value.length) {
|
||||
if (!onboardingStatus.value[user]) {
|
||||
onboardingStatus.value[user] = {}
|
||||
}
|
||||
|
||||
onboardingStatus.value[user][appName + '_onboarding_status'] =
|
||||
onboardings[appName].map((s) => {
|
||||
return { name: s.name, completed: value }
|
||||
})
|
||||
} else {
|
||||
onboardingSteps.value.forEach((step) => {
|
||||
step.completed = value
|
||||
})
|
||||
}
|
||||
|
||||
onboardings[appName].forEach((step) => {
|
||||
step.completed = value
|
||||
})
|
||||
|
||||
updateUserOnboardingStatus(onboardingSteps.value)
|
||||
|
||||
callback?.(value)
|
||||
}
|
||||
|
||||
function updateUserOnboardingStatus(steps) {
|
||||
call('jingrow.onboarding.update_user_onboarding_status', {
|
||||
steps: JSON.stringify(steps),
|
||||
appName,
|
||||
})
|
||||
}
|
||||
|
||||
function syncStatus() {
|
||||
if (isOnboardingStepsCompleted.value) return
|
||||
|
||||
if (onboardingSteps.value.length) {
|
||||
let _steps = onboardingSteps.value
|
||||
_steps.forEach((step, index) => {
|
||||
onboardings[appName][index].completed = step.completed
|
||||
})
|
||||
isOnboardingStepsCompleted.value = _steps.every((step) => step.completed)
|
||||
} else {
|
||||
isOnboardingStepsCompleted.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setUp(steps) {
|
||||
showHelpModal.value = !isOnboardingStepsCompleted.value
|
||||
|
||||
if (onboardings[appName]) return
|
||||
|
||||
onboardings[appName] = steps
|
||||
syncStatus()
|
||||
}
|
||||
|
||||
return {
|
||||
steps: onboardings[appName],
|
||||
stepsCompleted,
|
||||
totalSteps,
|
||||
completedPercentage,
|
||||
isOnboardingStepsCompleted,
|
||||
updateOnboardingStep,
|
||||
skip,
|
||||
skipAll,
|
||||
reset,
|
||||
resetAll,
|
||||
setUp,
|
||||
syncStatus,
|
||||
}
|
||||
}
|
||||
21
jingrow/index.js
Normal file
21
jingrow/index.js
Normal file
@ -0,0 +1,21 @@
|
||||
// help components
|
||||
export { default as HelpModal } from './Help/HelpModal.vue'
|
||||
|
||||
// onboarding components
|
||||
export { default as GettingStartedBanner } from './Onboarding/GettingStartedBanner.vue'
|
||||
export { default as OnboardingSteps } from './Onboarding/OnboardingSteps.vue'
|
||||
export { default as IntermediateStepModal } from './Onboarding/IntermediateStepModal.vue'
|
||||
|
||||
// help center components
|
||||
export { default as HelpCenter } from './HelpCenter/HelpCenter.vue'
|
||||
|
||||
// billing components
|
||||
export { default as TrialBanner } from './Billing/TrialBanner.vue'
|
||||
export { default as SignupBanner } from './Billing/SignupBanner.vue'
|
||||
|
||||
// composables
|
||||
export { useOnboarding } from './Onboarding/onboarding.js'
|
||||
|
||||
// utils
|
||||
export { showHelpModal, minimize } from './Help/help.js'
|
||||
export { showHelpCenter } from './HelpCenter/helpCenter.js'
|
||||
8
jingrow/session.js
Normal file
8
jingrow/session.js
Normal file
@ -0,0 +1,8 @@
|
||||
export function sessionUser() {
|
||||
let cookies = new URLSearchParams(document.cookie.split('; ').join('&'))
|
||||
let _sessionUser = cookies.get('user_id')
|
||||
if (_sessionUser === 'Guest') {
|
||||
_sessionUser = null
|
||||
}
|
||||
return _sessionUser
|
||||
}
|
||||
8
license.md
Normal file
8
license.md
Normal file
@ -0,0 +1,8 @@
|
||||
The MIT License (MIT)
|
||||
Copyright © 2022 JINGROW
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
5
main.js
Normal file
5
main.js
Normal file
@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import './src/style.css'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
112
package.json
Normal file
112
package.json
Normal file
@ -0,0 +1,112 @@
|
||||
{
|
||||
"name": "jingrow-ui",
|
||||
"version": "0.1.205",
|
||||
"description": "A set of components and utilities for rapid UI development",
|
||||
"main": "./src/index.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "vitest --run",
|
||||
"type-check": "tsc --noEmit",
|
||||
"prettier": "yarn prettier -w ./src",
|
||||
"bump-and-release": "yarn test && git pull --rebase origin main && yarn run release-patch",
|
||||
"release-patch": "yarn version --patch && git push && git push --tags",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"story:dev": "histoire dev",
|
||||
"story:build": "histoire build && cp 404.html .histoire/dist",
|
||||
"story:preview": "histoire preview"
|
||||
},
|
||||
"files": [
|
||||
"jingrow",
|
||||
"src",
|
||||
"scripts",
|
||||
"vite"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/jingrow/jingrow-ui.git"
|
||||
},
|
||||
"author": "JINGROW",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/vue": "^1.1.6",
|
||||
"@headlessui/vue": "^1.7.14",
|
||||
"@popperjs/core": "^2.11.2",
|
||||
"@tailwindcss/forms": "^0.5.3",
|
||||
"@tailwindcss/line-clamp": "^0.4.4",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tiptap/core": "^2.26.1",
|
||||
"@tiptap/extension-code-block": "^2.26.1",
|
||||
"@tiptap/extension-code-block-lowlight": "^2.26.1",
|
||||
"@tiptap/extension-color": "^2.26.1",
|
||||
"@tiptap/extension-heading": "^2.26.1",
|
||||
"@tiptap/extension-highlight": "^2.26.1",
|
||||
"@tiptap/extension-image": "^2.26.1",
|
||||
"@tiptap/extension-link": "^2.26.1",
|
||||
"@tiptap/extension-mention": "^2.26.1",
|
||||
"@tiptap/extension-placeholder": "^2.26.1",
|
||||
"@tiptap/extension-table": "^2.26.1",
|
||||
"@tiptap/extension-table-cell": "^2.26.1",
|
||||
"@tiptap/extension-table-header": "^2.26.1",
|
||||
"@tiptap/extension-table-row": "^2.26.1",
|
||||
"@tiptap/extension-task-item": "^2.26.1",
|
||||
"@tiptap/extension-task-list": "^2.26.1",
|
||||
"@tiptap/extension-text-align": "^2.26.1",
|
||||
"@tiptap/extension-text-style": "^2.26.1",
|
||||
"@tiptap/extension-typography": "^2.26.1",
|
||||
"@tiptap/pm": "^2.26.1",
|
||||
"@tiptap/starter-kit": "^2.26.1",
|
||||
"@tiptap/suggestion": "^2.26.1",
|
||||
"@tiptap/vue-3": "^2.26.1",
|
||||
"@vueuse/core": "^10.4.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"dompurify": "^3.2.6",
|
||||
"echarts": "^5.6.0",
|
||||
"feather-icons": "^4.28.0",
|
||||
"grid-layout-plus": "^1.1.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"idb-keyval": "^6.2.0",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-static": "^0.535.0",
|
||||
"marked": "^15.0.12",
|
||||
"ora": "5.4.1",
|
||||
"prettier": "^3.3.2",
|
||||
"radix-vue": "^1.5.3",
|
||||
"reka-ui": "^2.5.0",
|
||||
"socket.io-client": "^4.5.1",
|
||||
"tippy.js": "^6.3.7",
|
||||
"typescript": "^5.0.2",
|
||||
"unplugin-auto-import": "^19.3.0",
|
||||
"unplugin-icons": "^22.1.0",
|
||||
"unplugin-vue-components": "^28.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": ">=3.5.0",
|
||||
"vue-router": "^4.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@histoire/plugin-vue": "^0.17.17",
|
||||
"@vitejs/plugin-vue": "^4.0.0",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"histoire": "^0.17.17",
|
||||
"lint-staged": ">=10",
|
||||
"msw": "^2.7.0",
|
||||
"postcss": "^8.4.21",
|
||||
"prettier-plugin-tailwindcss": "^0.1.13",
|
||||
"tailwindcss": "^3.2.7",
|
||||
"vite": "^5.1.8",
|
||||
"vitest": "^2.1.8",
|
||||
"vue": "^3.3.0",
|
||||
"vue-router": "^4.1.6"
|
||||
},
|
||||
"resolutions": {
|
||||
"prosemirror-model": "1.25.2",
|
||||
"prosemirror-state": "1.4.3",
|
||||
"prosemirror-view": "1.40.0",
|
||||
"prosemirror-transform": "1.10.4"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,css,md,vue}": "prettier --write"
|
||||
}
|
||||
}
|
||||
6
postcss.config.ts
Normal file
6
postcss.config.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
public/jingrow-ui-square.png
Normal file
BIN
public/jingrow-ui-square.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
106
readme.md
Normal file
106
readme.md
Normal file
@ -0,0 +1,106 @@
|
||||
<div align="center" markdown="1">
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/0a81cdc1-d957-47a9-b151-f5571be0d038" width="80" />
|
||||
|
||||
# Jingrow UI
|
||||
**Rapidly build modern frontends for Jingrow apps**
|
||||
|
||||
<img alt="NPM Downloads" src="https://img.shields.io/npm/dm/jingrow-ui.svg?style=flat"/>
|
||||
|
||||
<a href="https://ui.jingrow.com">
|
||||
<img width="1292" alt="Screenshot 2024-12-12 at 5 27 58 PM" src="https://github.com/user-attachments/assets/56800b45-2859-4dc5-92b8-e40959ce4902" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
## Jingrow UI
|
||||
|
||||
Jingrow UI provides a set of components and utilities for rapid UI development. Components are built using Vue 3 and Tailwind.
|
||||
Along with generic components like Button, Link, Dialog, etc., it also contains utilities for handling server-side data fetching, directives and utilities.
|
||||
|
||||
|
||||
### Motivation
|
||||
In 2019, I began building [Jingrow Books](https://github.com/jingrow/books) which had a new design. This led to the creation of small reusable components like Button, Dialog, and Card. Moving on to [Jingrow Cloud](https://github.com/jingrow/press) in 2020, I reused and evolved these components in the Jingrow Cloud UI. In 2022, while starting a new project, I decided to extract these components into a standalone package to avoid repeating the copy-paste process. This package is now being developed alongside the [Gameplan](https://github.com/jingrow/gameplan), continually adding generic components and utilities for frontend development.
|
||||
|
||||
### Under the Hood
|
||||
|
||||
- [TailwindCSS](https://github.com/tailwindlabs/tailwindcss): Utility first CSS Framework to build design system based UI.
|
||||
- [Headless UI](https://github.com/tailwindlabs/headlessui): Unstyled and accessible UI components.
|
||||
- [TipTap](https://github.com/ueberdosis/tiptap): ProseMirror based rich-text editor with a Vue API.
|
||||
- [dayjs](https://github.com/iamkun/dayjs): Minimal javascript library for working with dates.
|
||||
|
||||
## Links
|
||||
|
||||
- [Documentation](https://jingrowui.com)
|
||||
- [Jingrow UI Starter Boilerplate](https://github.com/netchampfaris/jingrow-ui-starter)
|
||||
- [Community](https://github.com/jingrow/jingrow-ui/discussions)
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
npm install jingrow-ui
|
||||
# or
|
||||
yarn add jingrow-ui
|
||||
```
|
||||
|
||||
Now, import the JingrowUI plugin and components in your Vue app's `main.js`:
|
||||
|
||||
```js
|
||||
import { createApp } from 'vue'
|
||||
import { JingrowUI } from 'jingrow-ui'
|
||||
import App from './App.vue'
|
||||
import './index.css'
|
||||
|
||||
let app = createApp(App)
|
||||
app.use(JingrowUI)
|
||||
app.mount('#app')
|
||||
```
|
||||
|
||||
In your `tailwind.config.js` file, include the jingrow-ui preset:
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
presets: [
|
||||
require('jingrow-ui/src/utils/tailwind.config')
|
||||
],
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Now, you can import needed components and start using it:
|
||||
|
||||
```html
|
||||
<template>
|
||||
<button>Click me</button>
|
||||
</template>
|
||||
<script>
|
||||
import { Button } from 'jingrow-ui'
|
||||
export default {
|
||||
components: {
|
||||
Button,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Used By
|
||||
|
||||
Jingrow UI is being used in a lot of products by
|
||||
[Jingrow](https://github.com/jingrow).
|
||||
|
||||
- [Jingrow Cloud](https://jcloud.jingrow.com)
|
||||
- [Gameplan](https://github.com/jingrow/gameplan)
|
||||
- [Helpdesk](https://github.com/jingrow/helpdesk)
|
||||
- [Jingrow Insights](https://github.com/jingrow/insights)
|
||||
- [Jingrow Drive](https://github.com/jingrow/drive)
|
||||
- [Jingrow Builder](https://github.com/jingrow/builder)
|
||||
|
||||
<br>
|
||||
<br>
|
||||
<div align="center">
|
||||
<a href="https://framework.jingrow.com" target="_blank">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://framework.jingrow.com/files/Jingrow-white.png">
|
||||
<img src="https://framework.jingrow.com/files/Jingrow-black.png" alt="JINGROW" height="28"/>
|
||||
</picture>
|
||||
</a>
|
||||
</div>
|
||||
52
src/components/Alert/Alert.vue
Normal file
52
src/components/Alert/Alert.vue
Normal file
@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div class="block w-full">
|
||||
<div
|
||||
class="flex items-start rounded-md px-4 py-3.5 text-base md:px-5"
|
||||
:class="classes"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
opacity="0.8"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M12 2C6.5 2 2 6.5 2 12C2 17.5 6.5 22 12 22C17.5 22 22 17.5 22 12C22 6.5 17.5 2 12 2ZM12 10.5C12.5523 10.5 13 10.9477 13 11.5V17C13 17.5523 12.5523 18 12 18C11.4477 18 11 17.5523 11 17V11.5C11 10.9477 11.4477 10.5 12 10.5ZM13 7.99976C13 7.44747 12.5523 6.99976 12 6.99976C11.4477 6.99976 11 7.44747 11 7.99976V8.1C11 8.65228 11.4477 9.1 12 9.1C12.5523 9.1 13 8.65228 13 8.1V7.99976Z"
|
||||
fill="#318AD8"
|
||||
/>
|
||||
</svg>
|
||||
<div class="ml-2 w-full">
|
||||
<div class="flex flex-col md:flex-row md:items-baseline">
|
||||
<h3 class="text-lg font-medium text-ink-gray-9" v-if="title">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div class="mt-1 md:ml-2 md:mt-0">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div class="mt-3 md:ml-auto md:mt-0">
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { AlertProps } from './types'
|
||||
|
||||
const props = withDefaults(defineProps<AlertProps>(), {
|
||||
type: 'warning',
|
||||
})
|
||||
|
||||
const classes = computed(() => {
|
||||
return {
|
||||
warning: 'text-ink-gray-7 bg-surface-blue-1',
|
||||
}[props.type]
|
||||
})
|
||||
</script>
|
||||
2
src/components/Alert/index.ts
Normal file
2
src/components/Alert/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { default as Alert } from './Alert.vue'
|
||||
export type { AlertProps } from './types.ts'
|
||||
4
src/components/Alert/types.ts
Normal file
4
src/components/Alert/types.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface AlertProps {
|
||||
title?: string
|
||||
type?: 'warning'
|
||||
}
|
||||
104
src/components/Autocomplete/Autocomplete.story.vue
Normal file
104
src/components/Autocomplete/Autocomplete.story.vue
Normal file
@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import Autocomplete from './Autocomplete.vue'
|
||||
|
||||
const single = ref()
|
||||
const people = ref(null)
|
||||
const options = [
|
||||
{
|
||||
label: 'John Doe',
|
||||
value: 'john-doe',
|
||||
image: 'https://randomuser.me/api/portraits/men/59.jpg',
|
||||
},
|
||||
{
|
||||
label: 'Jane Doe',
|
||||
value: 'jane-doe',
|
||||
image: 'https://randomuser.me/api/portraits/women/58.jpg',
|
||||
},
|
||||
{
|
||||
label: 'John Smith',
|
||||
value: 'john-smith',
|
||||
image: 'https://randomuser.me/api/portraits/men/59.jpg',
|
||||
},
|
||||
{
|
||||
label: 'Jane Smith',
|
||||
value: 'jane-smith',
|
||||
image: 'https://randomuser.me/api/portraits/women/59.jpg',
|
||||
},
|
||||
{
|
||||
label: 'John Wayne',
|
||||
value: 'john-wayne',
|
||||
image: 'https://randomuser.me/api/portraits/men/57.jpg',
|
||||
},
|
||||
{
|
||||
label: 'Jane Wayne',
|
||||
value: 'jane-wayne',
|
||||
image: 'https://randomuser.me/api/portraits/women/51.jpg',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
<template>
|
||||
<Story :layout="{ width: 500, type: 'grid' }" autoPropsDisabled>
|
||||
<Variant title="Single option">
|
||||
<div class="p-2">
|
||||
<Autocomplete
|
||||
:options="options"
|
||||
v-model="single"
|
||||
placeholder="Select person"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Single option with prefix slots">
|
||||
<div class="p-2">
|
||||
<Autocomplete
|
||||
:options="options"
|
||||
v-model="single"
|
||||
placeholder="Select person"
|
||||
>
|
||||
<template #prefix>
|
||||
<img
|
||||
v-if="single"
|
||||
:src="single.image"
|
||||
class="mr-2 h-4 w-4 rounded-full"
|
||||
/>
|
||||
</template>
|
||||
<template #item-prefix="{ option }">
|
||||
<img :src="option.image" class="h-4 w-4 rounded-full" />
|
||||
</template>
|
||||
</Autocomplete>
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Single option without search">
|
||||
<div class="p-2">
|
||||
<Autocomplete
|
||||
:options="options"
|
||||
v-model="single"
|
||||
placeholder="Select person"
|
||||
:hideSearch="true"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Multiple options">
|
||||
<div class="p-2">
|
||||
<Autocomplete
|
||||
:options="options"
|
||||
v-model="people"
|
||||
placeholder="Select people"
|
||||
:multiple="true"
|
||||
:compareFn="(a, b) => a.value === b.value"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Multiple options without search">
|
||||
<div class="p-2">
|
||||
<Autocomplete
|
||||
:options="options"
|
||||
v-model="people"
|
||||
placeholder="Select people"
|
||||
:multiple="true"
|
||||
:hideSearch="true"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
408
src/components/Autocomplete/Autocomplete.vue
Normal file
408
src/components/Autocomplete/Autocomplete.vue
Normal file
@ -0,0 +1,408 @@
|
||||
<template>
|
||||
<Combobox
|
||||
v-model="selectedValue"
|
||||
:multiple="multiple"
|
||||
nullable
|
||||
:by="compareFn"
|
||||
v-slot="{ open: isComboboxOpen }"
|
||||
>
|
||||
<Popover
|
||||
class="w-full"
|
||||
v-model:show="showOptions"
|
||||
ref="rootRef"
|
||||
:placement="placement"
|
||||
:match-target-width="true"
|
||||
>
|
||||
<template
|
||||
#target="{ open: openPopover, togglePopover, close: closePopover }"
|
||||
>
|
||||
<slot
|
||||
name="target"
|
||||
v-bind="{
|
||||
open: openPopover,
|
||||
close: closePopover,
|
||||
togglePopover,
|
||||
isOpen: isComboboxOpen,
|
||||
}"
|
||||
>
|
||||
<div class="w-full space-y-1.5">
|
||||
<label v-if="props.label" class="block text-xs text-ink-gray-5">
|
||||
{{ props.label }}
|
||||
</label>
|
||||
<button
|
||||
class="flex h-7 w-full items-center justify-between gap-2 rounded bg-surface-gray-2 px-2 py-1 transition-colors hover:bg-surface-gray-3 border border-transparent focus:border-outline-gray-4 focus:outline-none focus:ring-2 focus:ring-outline-gray-3"
|
||||
:class="{ 'bg-surface-gray-3': isComboboxOpen }"
|
||||
@click="() => togglePopover()"
|
||||
>
|
||||
<div class="flex items-center overflow-hidden">
|
||||
<slot name="prefix" />
|
||||
<span
|
||||
class="truncate text-base leading-5 text-ink-gray-8"
|
||||
v-if="displayValue"
|
||||
>
|
||||
{{ displayValue }}
|
||||
</span>
|
||||
<span class="text-base leading-5 text-ink-gray-4" v-else>
|
||||
{{ placeholder || '' }}
|
||||
</span>
|
||||
<slot name="suffix" />
|
||||
</div>
|
||||
<FeatherIcon
|
||||
name="chevron-down"
|
||||
class="h-4 w-4 text-ink-gray-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</slot>
|
||||
</template>
|
||||
<template #body="{ isOpen, togglePopover }">
|
||||
<div v-show="isOpen">
|
||||
<div
|
||||
class="relative mt-1 rounded-lg bg-surface-modal text-base shadow-2xl"
|
||||
:class="bodyClasses"
|
||||
>
|
||||
<ComboboxOptions
|
||||
class="max-h-[15rem] overflow-y-auto px-1.5 pb-1.5"
|
||||
:class="{ 'pt-1.5': hideSearch }"
|
||||
static
|
||||
>
|
||||
<div
|
||||
v-if="!hideSearch"
|
||||
class="sticky top-0 z-10 flex items-stretch space-x-1.5 bg-surface-modal py-1.5"
|
||||
>
|
||||
<div class="relative w-full">
|
||||
<ComboboxInput
|
||||
ref="searchInput"
|
||||
class="form-input w-full focus:bg-surface-gray-3 hover:bg-surface-gray-4 text-ink-gray-8"
|
||||
type="text"
|
||||
:value="query"
|
||||
@change="query = $event.target.value"
|
||||
autocomplete="off"
|
||||
placeholder="Search"
|
||||
/>
|
||||
<div
|
||||
class="absolute right-0 inline-flex h-7 w-7 items-center justify-center"
|
||||
>
|
||||
<LoadingIndicator
|
||||
v-if="props.loading"
|
||||
class="h-4 w-4 text-ink-gray-5"
|
||||
/>
|
||||
<button v-else @click="clearAll">
|
||||
<FeatherIcon name="x" class="w-4 text-ink-gray-8" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="group in groups"
|
||||
:key="group.key"
|
||||
v-show="group.items.length > 0"
|
||||
>
|
||||
<div
|
||||
v-if="group.group && !group.hideLabel"
|
||||
class="sticky top-10 truncate bg-surface-modal px-2.5 py-1.5 text-sm font-medium text-ink-gray-5"
|
||||
>
|
||||
{{ group.group }}
|
||||
</div>
|
||||
<ComboboxOption
|
||||
as="template"
|
||||
v-for="(option, idx) in group.items.slice(
|
||||
0,
|
||||
props.maxOptions,
|
||||
)"
|
||||
:key="idx"
|
||||
:value="option"
|
||||
:disabled="option.disabled"
|
||||
v-slot="{ active, selected }"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'flex cursor-pointer items-center justify-between rounded px-2.5 py-1.5 text-base',
|
||||
{
|
||||
'bg-surface-gray-3': active,
|
||||
'opacity-50': option.disabled,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<div class="flex flex-1 gap-2 overflow-hidden items-center">
|
||||
<div
|
||||
v-if="$slots['item-prefix'] || props.multiple"
|
||||
class="flex flex-shrink-0"
|
||||
>
|
||||
<slot
|
||||
name="item-prefix"
|
||||
v-bind="{ active, selected, option }"
|
||||
>
|
||||
<FeatherIcon
|
||||
name="check"
|
||||
v-if="isOptionSelected(option)"
|
||||
class="h-4 w-4 text-ink-gray-7"
|
||||
/>
|
||||
<div v-else class="h-4 w-4" />
|
||||
</slot>
|
||||
</div>
|
||||
<span class="flex-1 truncate text-ink-gray-7">
|
||||
{{ getLabel(option) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="$slots['item-suffix'] || option?.description"
|
||||
class="ml-2 flex-shrink-0"
|
||||
>
|
||||
<slot
|
||||
name="item-suffix"
|
||||
v-bind="{ active, selected, option }"
|
||||
>
|
||||
<div
|
||||
v-if="option?.description"
|
||||
class="text-sm text-ink-gray-5"
|
||||
>
|
||||
{{ option.description }}
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</div>
|
||||
<li
|
||||
v-if="groups.length == 0"
|
||||
class="rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
|
||||
>
|
||||
No results found
|
||||
</li>
|
||||
</ComboboxOptions>
|
||||
|
||||
<div
|
||||
v-if="$slots.footer || props.showFooter || multiple"
|
||||
class="border-t p-1"
|
||||
>
|
||||
<slot name="footer" v-bind="{ togglePopover }">
|
||||
<div v-if="multiple" class="flex items-center justify-end">
|
||||
<Button
|
||||
v-if="!areAllOptionsSelected"
|
||||
label="Select All"
|
||||
@click.stop="selectAll"
|
||||
/>
|
||||
<Button
|
||||
v-if="areAllOptionsSelected"
|
||||
label="Clear All"
|
||||
@click.stop="clearAll"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex items-center justify-end">
|
||||
<Button label="Clear" @click.stop="clearAll" />
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</Combobox>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxOption,
|
||||
ComboboxOptions,
|
||||
} from '@headlessui/vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { Popover } from '../Popover'
|
||||
import { Button } from '../Button'
|
||||
import FeatherIcon from '../FeatherIcon.vue'
|
||||
import LoadingIndicator from '../LoadingIndicator.vue'
|
||||
import type {
|
||||
AutocompleteOptionGroup,
|
||||
AutocompleteOption,
|
||||
AutocompleteProps,
|
||||
Option,
|
||||
} from './types'
|
||||
|
||||
const props = withDefaults(defineProps<AutocompleteProps>(), {
|
||||
multiple: false,
|
||||
maxOptions: 50,
|
||||
hideSearch: false,
|
||||
compareFn: (a, b) => a.value === b.value,
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'update:query', 'change'])
|
||||
|
||||
const searchInput = ref()
|
||||
const showOptions = ref(false)
|
||||
const query = ref('')
|
||||
|
||||
const groups = computed(() => {
|
||||
if (!props.options?.length) return []
|
||||
|
||||
let groups: AutocompleteOptionGroup[]
|
||||
if (isOptionGroup(props.options[0])) {
|
||||
groups = props.options as AutocompleteOptionGroup[]
|
||||
} else {
|
||||
groups = [
|
||||
{
|
||||
group: '',
|
||||
items: sanitizeOptions(props.options as AutocompleteOption[]),
|
||||
hideLabel: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return groups
|
||||
.map((group, i) => {
|
||||
return {
|
||||
key: i,
|
||||
group: group.group,
|
||||
hideLabel: group.hideLabel,
|
||||
items: filterOptions(sanitizeOptions(group.items || [])),
|
||||
}
|
||||
})
|
||||
.filter((group) => group.items.length > 0)
|
||||
})
|
||||
|
||||
const allOptions = computed(() => {
|
||||
return groups.value.flatMap((group) => group.items)
|
||||
})
|
||||
|
||||
const sanitizeOptions = (options: AutocompleteOption[]) => {
|
||||
if (!options) return []
|
||||
// in case the options are just values, convert them to objects
|
||||
return options.map((option) => {
|
||||
return isOption(option)
|
||||
? option
|
||||
: { label: option.toString(), value: option }
|
||||
})
|
||||
}
|
||||
|
||||
const filterOptions = (options: Option[]) => {
|
||||
if (!query.value) return options
|
||||
return options.filter((option) => {
|
||||
return (
|
||||
option.label.toLowerCase().includes(query.value.trim().toLowerCase()) ||
|
||||
option.value
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.includes(query.value.trim().toLowerCase())
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const selectedValue = computed({
|
||||
get() {
|
||||
if (!props.multiple) {
|
||||
return (
|
||||
findOption(props.modelValue as AutocompleteOption) ||
|
||||
// if the modelValue is not found in the option list
|
||||
// return the modelValue as is
|
||||
makeOption(props.modelValue as AutocompleteOption)
|
||||
)
|
||||
}
|
||||
// in case of `multiple`, modelValue is an array of values
|
||||
// if the modelValue is a list of values, convert them to options
|
||||
const values = (props.modelValue || []) as AutocompleteOption[]
|
||||
return isOption(values[0])
|
||||
? values
|
||||
: values.map((v) => findOption(v) || makeOption(v))
|
||||
},
|
||||
set(val) {
|
||||
query.value = ''
|
||||
if (val && !props.multiple) showOptions.value = false
|
||||
if (!props.multiple) {
|
||||
emit('update:modelValue', val)
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', val)
|
||||
},
|
||||
})
|
||||
|
||||
const findOption = (option: AutocompleteOption) => {
|
||||
if (!option) return option
|
||||
const value = isOption(option) ? option.value : option
|
||||
return allOptions.value.find((o) => o.value === value)
|
||||
}
|
||||
|
||||
const makeOption = (option: AutocompleteOption) => {
|
||||
return isOption(option) ? option : { label: option, value: option }
|
||||
}
|
||||
|
||||
const getLabel = (option: AutocompleteOption) => {
|
||||
if (isOption(option)) {
|
||||
return option?.label || option?.value
|
||||
}
|
||||
return option
|
||||
}
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (!selectedValue.value) return ''
|
||||
if (!props.multiple) {
|
||||
return getLabel(selectedValue.value as AutocompleteOption)
|
||||
}
|
||||
return (selectedValue.value as AutocompleteOption[])
|
||||
.map((v) => getLabel(v))
|
||||
.join(', ')
|
||||
})
|
||||
|
||||
const isOptionSelected = (option: AutocompleteOption) => {
|
||||
if (!selectedValue.value) return false
|
||||
const value = isOption(option) ? option.value : option
|
||||
if (!props.multiple) {
|
||||
return selectedValue.value === value
|
||||
}
|
||||
return (selectedValue.value as AutocompleteOption[]).find((v) =>
|
||||
isOption(v) ? v.value === value : v === value,
|
||||
)
|
||||
}
|
||||
|
||||
const areAllOptionsSelected = computed(() => {
|
||||
if (!props.multiple) return false
|
||||
return (
|
||||
allOptions.value.length ===
|
||||
(selectedValue.value as AutocompleteOption[])?.length
|
||||
)
|
||||
})
|
||||
|
||||
const selectAll = () => {
|
||||
selectedValue.value = allOptions.value
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
selectedValue.value = props.multiple ? [] : undefined
|
||||
}
|
||||
|
||||
const isOption = (option: AutocompleteOption) => {
|
||||
return typeof option === 'object'
|
||||
}
|
||||
|
||||
const isOptionGroup = (option: any) => {
|
||||
return typeof option === 'object' && 'items' in option && 'group' in option
|
||||
}
|
||||
|
||||
watch(
|
||||
() => query.value,
|
||||
() => {
|
||||
emit('update:query', query.value)
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => showOptions.value,
|
||||
() => {
|
||||
if (showOptions.value) {
|
||||
nextTick(() => searchInput.value?.$el.focus())
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const rootRef = ref()
|
||||
|
||||
const togglePopover = () => {
|
||||
showOptions.value = !showOptions.value
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
rootRef,
|
||||
togglePopover,
|
||||
})
|
||||
</script>
|
||||
2
src/components/Autocomplete/index.ts
Normal file
2
src/components/Autocomplete/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { default as Autocomplete } from './Autocomplete.vue'
|
||||
export type { AutocompleteProps } from './types'
|
||||
40
src/components/Autocomplete/types.ts
Normal file
40
src/components/Autocomplete/types.ts
Normal file
@ -0,0 +1,40 @@
|
||||
type OptionValue = string | number | boolean
|
||||
|
||||
export type Option = {
|
||||
label: string
|
||||
value: OptionValue
|
||||
description?: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export type AutocompleteOption = OptionValue | Option
|
||||
|
||||
export type AutocompleteOptionGroup = {
|
||||
group: string
|
||||
items: AutocompleteOption[]
|
||||
hideLabel?: boolean
|
||||
}
|
||||
|
||||
type AutocompleteOptions = AutocompleteOption[] | AutocompleteOptionGroup[]
|
||||
|
||||
export type AutocompleteProps = {
|
||||
label?: string
|
||||
options: AutocompleteOptions
|
||||
hideSearch?: boolean
|
||||
placeholder?: string
|
||||
bodyClasses?: string | string[]
|
||||
loading?: boolean
|
||||
placement?: string
|
||||
showFooter?: boolean
|
||||
compareFn?: (a: Option, b: Option) => boolean
|
||||
maxOptions?: number
|
||||
} & (
|
||||
| {
|
||||
multiple: true
|
||||
modelValue?: AutocompleteOption[] | null
|
||||
}
|
||||
| {
|
||||
multiple?: false
|
||||
modelValue?: AutocompleteOption | null
|
||||
}
|
||||
)
|
||||
28
src/components/Avatar/Avatar.story.vue
Normal file
28
src/components/Avatar/Avatar.story.vue
Normal file
@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
import Avatar from './Avatar.vue'
|
||||
const state = reactive({
|
||||
image: 'https://avatars.githubusercontent.com/u/499550?s=60&v=4',
|
||||
label: 'EY',
|
||||
size: 'md',
|
||||
})
|
||||
|
||||
const shapes = ['circle', 'square']
|
||||
const sizes = ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl']
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story :layout="{ type: 'grid', width: 300 }">
|
||||
<Variant v-for="shape in shapes" :key="shape" :title="shape">
|
||||
<Avatar :shape="shape" v-bind="state" />
|
||||
</Variant>
|
||||
<Variant v-for="shape in shapes" :key="shape" :title="shape">
|
||||
<Avatar :shape="shape" v-bind="state" :image="null" />
|
||||
</Variant>
|
||||
|
||||
<template #controls>
|
||||
<HstText v-model="state.label" title="Label" />
|
||||
<HstSelect v-model="state.size" :options="sizes" title="Size" />
|
||||
</template>
|
||||
</Story>
|
||||
</template>
|
||||
131
src/components/Avatar/Avatar.vue
Normal file
131
src/components/Avatar/Avatar.vue
Normal file
@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative inline-block shrink-0"
|
||||
:class="[sizeClasses, shapeClasses]"
|
||||
>
|
||||
<img
|
||||
v-if="image && !imgFetchError"
|
||||
:src="image"
|
||||
:alt="label"
|
||||
:class="[shapeClasses, 'h-full w-full object-cover']"
|
||||
@error="(err) => handleImageError(err)"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full w-full items-center justify-center bg-surface-gray-2 uppercase text-ink-gray-5 select-none"
|
||||
:class="[labelClasses, shapeClasses]"
|
||||
>
|
||||
<div :class="iconClasses" v-if="$slots.default">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<template v-else>
|
||||
{{ label && label[0] }}
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="$slots.indicator"
|
||||
:class="[
|
||||
'absolute bottom-0 right-0 grid place-items-center rounded-full bg-surface-white',
|
||||
indicatorContainerClasses,
|
||||
]"
|
||||
>
|
||||
<div :class="indicatorClasses">
|
||||
<slot name="indicator"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { AvatarProps } from './types'
|
||||
|
||||
const imgFetchError = ref(false)
|
||||
|
||||
const props = withDefaults(defineProps<AvatarProps>(), {
|
||||
size: 'md',
|
||||
shape: 'circle',
|
||||
})
|
||||
|
||||
const shapeClasses = computed(() => {
|
||||
return {
|
||||
circle: 'rounded-full',
|
||||
square: {
|
||||
xs: 'rounded-[4px]',
|
||||
sm: 'rounded-[5px]',
|
||||
md: 'rounded-[5px]',
|
||||
lg: 'rounded-[6px]',
|
||||
xl: 'rounded-[6px]',
|
||||
'2xl': 'rounded-[8px]',
|
||||
'3xl': 'rounded-[10px]',
|
||||
}[props.size],
|
||||
}[props.shape]
|
||||
})
|
||||
|
||||
const sizeClasses = computed(() => {
|
||||
return {
|
||||
xs: 'w-4 h-4',
|
||||
sm: 'w-5 h-5',
|
||||
md: 'w-6 h-6',
|
||||
lg: 'w-7 h-7',
|
||||
xl: 'w-8 h-8',
|
||||
'2xl': 'w-10 h-10',
|
||||
'3xl': 'w-11.5 h-11.5',
|
||||
}[props.size]
|
||||
})
|
||||
|
||||
const labelClasses = computed(() => {
|
||||
let sizeClass = {
|
||||
xs: 'text-2xs',
|
||||
sm: 'text-sm',
|
||||
md: 'text-base',
|
||||
lg: 'text-base',
|
||||
xl: 'text-lg',
|
||||
'2xl': 'text-xl',
|
||||
'3xl': 'text-2xl',
|
||||
}[props.size]
|
||||
return ['font-medium', sizeClass]
|
||||
})
|
||||
|
||||
const indicatorContainerClasses = computed(() => {
|
||||
return {
|
||||
xs: '-mr-[.1rem] -mb-[.1rem] h-2 w-2',
|
||||
sm: '-mr-[.1rem] -mb-[.1rem] h-[9px] w-[9px]',
|
||||
md: '-mr-[.1rem] -mb-[.1rem] h-2.5 w-2.5',
|
||||
lg: '-mr-[.1rem] -mb-[.1rem] h-3 w-3',
|
||||
xl: '-mr-[.1rem] -mb-[.1rem] h-3 w-3',
|
||||
'2xl': '-mr-[.1rem] -mb-[.1rem] h-3.5 w-3.5',
|
||||
'3xl': '-mr-[.2rem] -mb-[.2rem] h-4 w-4',
|
||||
}[props.size]
|
||||
})
|
||||
|
||||
const indicatorClasses = computed(() => {
|
||||
return {
|
||||
xs: 'h-1 w-1',
|
||||
sm: 'h-[5px] w-[5px]',
|
||||
md: 'h-1.5 w-1.5',
|
||||
lg: 'h-2 w-2',
|
||||
xl: 'h-2 w-2',
|
||||
'2xl': 'h-2.5 w-2.5',
|
||||
'3xl': 'h-3 w-3',
|
||||
}[props.size]
|
||||
})
|
||||
|
||||
const iconClasses = computed(() => {
|
||||
return {
|
||||
xs: 'h-2.5 w-2.5',
|
||||
sm: 'h-3 w-3',
|
||||
md: 'h-4 w-4',
|
||||
lg: 'h-4 w-4',
|
||||
xl: 'h-4 w-4',
|
||||
'2xl': 'h-5 w-5',
|
||||
'3xl': 'h-5 w-5',
|
||||
}[props.size]
|
||||
})
|
||||
|
||||
function handleImageError(err) {
|
||||
if (err.type) {
|
||||
imgFetchError.value = true
|
||||
}
|
||||
}
|
||||
</script>
|
||||
2
src/components/Avatar/index.ts
Normal file
2
src/components/Avatar/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { default as Avatar } from './Avatar.vue'
|
||||
export type { AvatarProps } from './types'
|
||||
6
src/components/Avatar/types.ts
Normal file
6
src/components/Avatar/types.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export interface AvatarProps {
|
||||
image?: string
|
||||
label?: string
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl'
|
||||
shape?: 'circle' | 'square'
|
||||
}
|
||||
26
src/components/Badge/Badge.story.vue
Normal file
26
src/components/Badge/Badge.story.vue
Normal file
@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
import Badge from './Badge.vue'
|
||||
const state = reactive({
|
||||
theme: 'gray',
|
||||
size: 'sm',
|
||||
label: 'Badge',
|
||||
})
|
||||
const variants = ['solid', 'subtle', 'outline', 'ghost']
|
||||
const themes = ['gray', 'blue', 'green', 'orange', 'red']
|
||||
const sizes = ['sm', 'md', 'lg']
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story :layout="{ type: 'grid', width: 300 }">
|
||||
<Variant v-for="variant in variants" :key="variant" :title="variant">
|
||||
<Badge :variant="variant" v-bind="state">{{ state.label }}</Badge>
|
||||
</Variant>
|
||||
|
||||
<template #controls>
|
||||
<HstText v-model="state.label" title="Content" />
|
||||
<HstSelect v-model="state.theme" :options="themes" title="Theme" />
|
||||
<HstSelect v-model="state.size" :options="sizes" title="Size" />
|
||||
</template>
|
||||
</Story>
|
||||
</template>
|
||||
80
src/components/Badge/Badge.vue
Normal file
80
src/components/Badge/Badge.vue
Normal file
@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex select-none items-center gap-1 rounded-full"
|
||||
:class="classes"
|
||||
>
|
||||
<div
|
||||
:class="[props.size == 'lg' ? 'max-h-6' : 'max-h-4']"
|
||||
v-if="$slots.prefix"
|
||||
>
|
||||
<slot name="prefix"></slot>
|
||||
</div>
|
||||
<slot>{{ props.label?.toString() }}</slot>
|
||||
<div
|
||||
:class="[props.size == 'lg' ? 'max-h-6' : 'max-h-4']"
|
||||
v-if="$slots.suffix"
|
||||
>
|
||||
<slot name="suffix"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import type { BadgeProps } from './types'
|
||||
|
||||
const props = withDefaults(defineProps<BadgeProps>(), {
|
||||
theme: 'gray',
|
||||
size: 'md',
|
||||
variant: 'subtle',
|
||||
})
|
||||
|
||||
const classes = computed(() => {
|
||||
let solidClasses = {
|
||||
gray: 'text-ink-white bg-surface-gray-7',
|
||||
blue: 'text-ink-blue-1 bg-surface-blue-2',
|
||||
green: 'text-ink-green-1 bg-surface-green-3',
|
||||
orange: 'text-ink-amber-1 bg-surface-amber-2',
|
||||
red: 'text-ink-red-1 bg-surface-red-4',
|
||||
}[props.theme]
|
||||
|
||||
let subtleClasses = {
|
||||
gray: 'text-ink-gray-6 bg-surface-gray-2',
|
||||
blue: 'text-ink-blue-2 bg-surface-blue-1',
|
||||
green: 'text-ink-green-3 bg-surface-green-2',
|
||||
orange: 'text-ink-amber-3 bg-surface-amber-1',
|
||||
red: 'text-ink-red-4 bg-surface-red-1',
|
||||
}[props.theme]
|
||||
|
||||
let outlineClasses = {
|
||||
gray: 'text-ink-gray-6 bg-transparent border border-outline-gray-1',
|
||||
blue: 'text-ink-blue-2 bg-transparent border border-outline-blue-1',
|
||||
green: 'text-ink-green-3 bg-transparent border border-outline-green-2',
|
||||
orange: 'text-ink-amber-3 bg-transparent border border-outline-amber-2',
|
||||
red: 'text-ink-red-4 bg-transparent border border-outline-red-2',
|
||||
}[props.theme]
|
||||
|
||||
let ghostClasses = {
|
||||
gray: 'text-ink-gray-6 bg-transparent',
|
||||
blue: 'text-ink-blue-2 bg-transparent',
|
||||
green: 'text-ink-green-3 bg-transparent',
|
||||
orange: 'text-ink-amber-3 bg-transparent',
|
||||
red: 'text-ink-red-4 bg-transparent',
|
||||
}[props.theme]
|
||||
|
||||
let variantClasses = {
|
||||
subtle: subtleClasses,
|
||||
solid: solidClasses,
|
||||
outline: outlineClasses,
|
||||
ghost: ghostClasses,
|
||||
}[props.variant]
|
||||
|
||||
let sizeClasses = {
|
||||
sm: 'h-4 text-xs px-1.5',
|
||||
md: 'h-5 text-xs px-1.5',
|
||||
lg: 'h-6 text-sm px-2',
|
||||
}[props.size]
|
||||
|
||||
return [variantClasses, sizeClasses]
|
||||
})
|
||||
</script>
|
||||
2
src/components/Badge/index.ts
Normal file
2
src/components/Badge/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { default as Badge } from './Badge.vue'
|
||||
export type { BadgeProps } from './types'
|
||||
10
src/components/Badge/types.ts
Normal file
10
src/components/Badge/types.ts
Normal file
@ -0,0 +1,10 @@
|
||||
interface Label {
|
||||
toString(): string
|
||||
}
|
||||
|
||||
export interface BadgeProps {
|
||||
theme?: 'gray' | 'blue' | 'green' | 'orange' | 'red'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
variant?: 'solid' | 'subtle' | 'outline' | 'ghost'
|
||||
label?: Label | string | number
|
||||
}
|
||||
73
src/components/Breadcrumbs/Breadcrumbs.story.vue
Normal file
73
src/components/Breadcrumbs/Breadcrumbs.story.vue
Normal file
@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { logEvent } from 'histoire/client'
|
||||
import Breadcrumbs from './Breadcrumbs.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story :layout="{ type: 'grid', width: 500 }">
|
||||
<Variant title="With route option">
|
||||
<Breadcrumbs
|
||||
:items="[
|
||||
{
|
||||
label: 'Home',
|
||||
route: { name: 'Home' },
|
||||
},
|
||||
{
|
||||
label: 'Views',
|
||||
route: '/components',
|
||||
},
|
||||
{
|
||||
label: 'List',
|
||||
route: '/components/breadcrumbs',
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</Variant>
|
||||
<Variant title="With onClick option">
|
||||
<Breadcrumbs
|
||||
:items="[
|
||||
{
|
||||
label: 'Home',
|
||||
onClick: () => logEvent('onClick', 'Home'),
|
||||
},
|
||||
{
|
||||
label: 'Views',
|
||||
onClick: () => logEvent('onClick', 'Home'),
|
||||
},
|
||||
{
|
||||
label: 'Kanban',
|
||||
onClick: () => logEvent('onClick', 'Home'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="With prefix slot">
|
||||
<Breadcrumbs
|
||||
:items="[
|
||||
{
|
||||
label: 'Home',
|
||||
icon: '🏡',
|
||||
route: { name: 'Home' },
|
||||
},
|
||||
{
|
||||
label: 'Views',
|
||||
icon: '🏞️',
|
||||
route: '/components',
|
||||
},
|
||||
{
|
||||
label: 'List',
|
||||
icon: '📃',
|
||||
route: '/components/breadcrumbs',
|
||||
},
|
||||
]"
|
||||
>
|
||||
<template #prefix="{ item }">
|
||||
<span class="mr-1">
|
||||
{{ item.icon }}
|
||||
</span>
|
||||
</template>
|
||||
</Breadcrumbs>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
123
src/components/Breadcrumbs/Breadcrumbs.vue
Normal file
123
src/components/Breadcrumbs/Breadcrumbs.vue
Normal file
@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div class="flex min-w-0 items-center">
|
||||
<template v-if="dropdownItems.length">
|
||||
<Dropdown class="h-7" :options="dropdownItems">
|
||||
<Button variant="ghost">
|
||||
<template #icon>
|
||||
<svg
|
||||
class="w-4 text-ink-gray-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
<circle cx="19" cy="12" r="1" />
|
||||
<circle cx="5" cy="12" r="1" />
|
||||
</svg>
|
||||
</template>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<span class="ml-1 mr-0.5 text-base text-ink-gray-4" aria-hidden="true">
|
||||
/
|
||||
</span>
|
||||
</template>
|
||||
<div
|
||||
class="flex min-w-0 items-center overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
<template v-for="(item, i) in crumbs" :key="item.label">
|
||||
<router-link
|
||||
v-if="item.route"
|
||||
:to="item.route"
|
||||
@click="item.onClick ? item.onClick() : null"
|
||||
class="flex items-center rounded px-0.5 py-1 text-lg font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-outline-gray-3"
|
||||
:class="[
|
||||
i == crumbs.length - 1
|
||||
? 'text-ink-gray-9'
|
||||
: 'text-ink-gray-5 hover:text-ink-gray-7',
|
||||
]"
|
||||
>
|
||||
<slot name="prefix" :item="item" />
|
||||
<span>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<slot name="suffix" :item="item" />
|
||||
</router-link>
|
||||
<button
|
||||
v-else
|
||||
@click="item.onClick ? item.onClick() : null"
|
||||
class="flex items-center rounded px-0.5 py-1 text-lg font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-outline-gray-3"
|
||||
:class="[
|
||||
i == crumbs.length - 1
|
||||
? 'text-ink-gray-9'
|
||||
: 'text-ink-gray-5 hover:text-ink-gray-7',
|
||||
]"
|
||||
>
|
||||
<slot name="prefix" :item="item" />
|
||||
<span>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<slot name="suffix" :item="item" />
|
||||
</button>
|
||||
<span
|
||||
v-if="i != crumbs.length - 1"
|
||||
class="mx-0.5 text-base text-ink-gray-4"
|
||||
aria-hidden="true"
|
||||
>
|
||||
/
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Dropdown } from '../Dropdown'
|
||||
import { Button } from '../Button'
|
||||
import type { BreadcrumbsProps } from './types'
|
||||
|
||||
const props = defineProps<BreadcrumbsProps>()
|
||||
|
||||
const router = useRouter()
|
||||
const { width } = useWindowSize()
|
||||
|
||||
const items = computed(() => {
|
||||
return (props.items || []).filter(Boolean)
|
||||
})
|
||||
|
||||
const dropdownItems = computed(() => {
|
||||
if (width.value > 640) return []
|
||||
|
||||
let allExceptLastTwo = items.value.slice(0, -2)
|
||||
return allExceptLastTwo.map((item) => {
|
||||
let onClick = () => {
|
||||
if (item.onClick) {
|
||||
item.onClick()
|
||||
}
|
||||
if (item.route) {
|
||||
router.push(item.route)
|
||||
}
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
icon: null,
|
||||
label: item.label,
|
||||
onClick,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const crumbs = computed(() => {
|
||||
if (width.value > 640) return items.value
|
||||
|
||||
let lastTwo = items.value.slice(-2)
|
||||
return lastTwo
|
||||
})
|
||||
</script>
|
||||
2
src/components/Breadcrumbs/index.ts
Normal file
2
src/components/Breadcrumbs/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { default as Breadcrumbs } from './Breadcrumbs.vue'
|
||||
export type { BreadcrumbsProps } from './types'
|
||||
12
src/components/Breadcrumbs/types.ts
Normal file
12
src/components/Breadcrumbs/types.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { RouterLinkProps } from 'vue-router'
|
||||
|
||||
interface BreadcrumbItem {
|
||||
label: string
|
||||
route?: RouterLinkProps['to']
|
||||
onClick?: () => void
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface BreadcrumbsProps {
|
||||
items: BreadcrumbItem[]
|
||||
}
|
||||
34
src/components/Button/Button.story.vue
Normal file
34
src/components/Button/Button.story.vue
Normal file
@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
import { Button } from './index'
|
||||
const state = reactive({
|
||||
theme: 'gray',
|
||||
size: 'sm',
|
||||
label: 'Button',
|
||||
loading: false,
|
||||
loadingText: null,
|
||||
disabled: false,
|
||||
link: null,
|
||||
tooltip: 'Hover for more!',
|
||||
})
|
||||
const variants = ['solid', 'subtle', 'outline', 'ghost']
|
||||
const themes = ['gray', 'blue', 'green', 'red']
|
||||
const sizes = ['sm', 'md', 'lg', 'xl', '2xl']
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story :layout="{ type: 'grid', width: 300 }">
|
||||
<Variant v-for="variant in variants" :key="variant" :title="variant">
|
||||
<div class="p-1">
|
||||
<Button :variant="variant" v-bind="state">{{ state.label }}</Button>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<template #controls>
|
||||
<HstText v-model="state.label" title="Content" />
|
||||
<HstCheckbox v-model="state.disabled" title="Disabled" />
|
||||
<HstSelect v-model="state.theme" :options="themes" title="Theme" />
|
||||
<HstSelect v-model="state.size" :options="sizes" title="Size" />
|
||||
</template>
|
||||
</Story>
|
||||
</template>
|
||||
238
src/components/Button/Button.vue
Normal file
238
src/components/Button/Button.vue
Normal file
@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<Tooltip :text="tooltip" :disabled="!tooltip?.length">
|
||||
<button
|
||||
v-bind="$attrs"
|
||||
:class="buttonClasses"
|
||||
@click="handleClick"
|
||||
:disabled="isDisabled"
|
||||
:ariaLabel="ariaLabel"
|
||||
ref="rootRef"
|
||||
>
|
||||
<LoadingIndicator
|
||||
v-if="loading"
|
||||
:class="{
|
||||
'h-3 w-3': size == 'sm',
|
||||
'h-[13.5px] w-[13.5px]': size == 'md',
|
||||
'h-[15px] w-[15px]': size == 'lg',
|
||||
'h-4.5 w-4.5': size == 'xl' || size == '2xl',
|
||||
}"
|
||||
/>
|
||||
<slot name="prefix" v-else-if="$slots['prefix'] || iconLeft">
|
||||
<FeatherIcon
|
||||
v-if="iconLeft && typeof iconLeft === 'string'"
|
||||
:name="iconLeft"
|
||||
:class="slotClasses"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<component v-else-if="iconLeft" :is="iconLeft" :class="slotClasses" />
|
||||
</slot>
|
||||
|
||||
<template v-if="loading && loadingText">{{ loadingText }}</template>
|
||||
<template v-else-if="isIconButton && !loading">
|
||||
<FeatherIcon
|
||||
v-if="icon && typeof icon === 'string'"
|
||||
:name="icon"
|
||||
:class="slotClasses"
|
||||
:aria-label="label"
|
||||
/>
|
||||
<component v-else-if="icon" :is="icon" :class="slotClasses" />
|
||||
<slot name="icon" v-else-if="$slots.icon" />
|
||||
<div v-else-if="hasLucideIconInDefaultSlot" :class="slotClasses">
|
||||
<slot>{{ label }}</slot>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else :class="{ 'sr-only': isIconButton }" class="truncate">
|
||||
<slot>{{ label }}</slot>
|
||||
</span>
|
||||
|
||||
<slot name="suffix">
|
||||
<FeatherIcon
|
||||
v-if="iconRight && typeof iconRight === 'string'"
|
||||
:name="iconRight"
|
||||
:class="slotClasses"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<component
|
||||
v-else-if="iconRight"
|
||||
:is="iconRight"
|
||||
:class="slotClasses"
|
||||
/>
|
||||
</slot>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { computed, useSlots, ref } from 'vue'
|
||||
import FeatherIcon from '../FeatherIcon.vue'
|
||||
import LoadingIndicator from '../LoadingIndicator.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { ButtonProps, ThemeVariant } from './types'
|
||||
import Tooltip from '../Tooltip/Tooltip.vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(defineProps<ButtonProps>(), {
|
||||
theme: 'gray',
|
||||
size: 'sm',
|
||||
variant: 'subtle',
|
||||
loading: false,
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const slots = useSlots()
|
||||
const router = useRouter()
|
||||
|
||||
const buttonClasses = computed(() => {
|
||||
let solidClasses = {
|
||||
gray: 'text-ink-white bg-surface-gray-7 hover:bg-surface-gray-6 active:bg-surface-gray-5',
|
||||
blue: 'text-ink-white bg-blue-500 hover:bg-surface-blue-3 active:bg-blue-700',
|
||||
green:
|
||||
'text-ink-white bg-surface-green-3 hover:bg-green-700 active:bg-green-800',
|
||||
red: 'text-ink-white bg-surface-red-5 hover:bg-surface-red-6 active:bg-surface-red-7',
|
||||
}[props.theme]
|
||||
|
||||
let subtleClasses = {
|
||||
gray: 'text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4',
|
||||
blue: 'text-ink-blue-3 bg-surface-blue-2 hover:bg-blue-200 active:bg-blue-300',
|
||||
green:
|
||||
'text-green-800 bg-surface-green-2 hover:bg-green-200 active:bg-green-300',
|
||||
red: 'text-red-700 bg-surface-red-2 hover:bg-surface-red-3 active:bg-surface-red-4',
|
||||
}[props.theme]
|
||||
|
||||
let outlineClasses = {
|
||||
gray: 'text-ink-gray-8 bg-surface-white bg-surface-white border border-outline-gray-2 hover:border-outline-gray-3 active:border-outline-gray-3 active:bg-surface-gray-4',
|
||||
blue: 'text-ink-blue-3 bg-surface-white border border-outline-blue-1 hover:border-blue-400 active:border-blue-400 active:bg-blue-300',
|
||||
green:
|
||||
'text-green-800 bg-surface-white border border-outline-green-2 hover:border-green-500 active:border-green-500 active:bg-green-300',
|
||||
red: 'text-red-700 bg-surface-white border border-outline-red-1 hover:border-outline-red-2 active:border-outline-red-2 active:bg-surface-red-3',
|
||||
}[props.theme]
|
||||
|
||||
let ghostClasses = {
|
||||
gray: 'text-ink-gray-8 bg-transparent hover:bg-surface-gray-3 active:bg-surface-gray-4',
|
||||
blue: 'text-ink-blue-3 bg-transparent hover:bg-blue-200 active:bg-blue-300',
|
||||
green:
|
||||
'text-green-800 bg-transparent hover:bg-green-200 active:bg-green-300',
|
||||
red: 'text-red-700 bg-transparent hover:bg-surface-red-3 active:bg-surface-red-4',
|
||||
}[props.theme]
|
||||
|
||||
let focusClasses = {
|
||||
gray: 'focus-visible:ring focus-visible:ring-outline-gray-3',
|
||||
blue: 'focus-visible:ring focus-visible:ring-blue-400',
|
||||
green: 'focus-visible:ring focus-visible:ring-outline-green-2',
|
||||
red: 'focus-visible:ring focus-visible:ring-outline-red-2',
|
||||
}[props.theme]
|
||||
|
||||
let variantClasses = {
|
||||
subtle: subtleClasses,
|
||||
solid: solidClasses,
|
||||
outline: outlineClasses,
|
||||
ghost: ghostClasses,
|
||||
}[props.variant]
|
||||
|
||||
let themeVariant: ThemeVariant = `${props.theme}-${props.variant}`
|
||||
|
||||
let disabledClassesMap: Record<ThemeVariant, string> = {
|
||||
'gray-solid': 'bg-surface-gray-2 text-ink-gray-4',
|
||||
'gray-subtle': 'bg-surface-gray-2 text-ink-gray-4',
|
||||
'gray-outline':
|
||||
'bg-surface-gray-2 text-ink-gray-4 border border-outline-gray-2',
|
||||
'gray-ghost': 'text-ink-gray-4',
|
||||
|
||||
'blue-solid': 'bg-blue-300 text-ink-white',
|
||||
'blue-subtle': 'bg-surface-blue-2 text-ink-blue-link',
|
||||
'blue-outline':
|
||||
'bg-surface-blue-2 text-ink-blue-link border border-outline-blue-1',
|
||||
'blue-ghost': 'text-ink-blue-link',
|
||||
|
||||
'green-solid': 'bg-surface-green-2 text-ink-green-2',
|
||||
'green-subtle': 'bg-surface-green-2 text-ink-green-2',
|
||||
'green-outline':
|
||||
'bg-surface-green-2 text-ink-green-2 border border-outline-green-2',
|
||||
'green-ghost': 'text-ink-green-2',
|
||||
|
||||
'red-solid': 'bg-surface-red-2 text-ink-red-2',
|
||||
'red-subtle': 'bg-surface-red-2 text-ink-red-2',
|
||||
'red-outline':
|
||||
'bg-surface-red-2 text-ink-red-2 border border-outline-red-1',
|
||||
'red-ghost': 'text-ink-red-2',
|
||||
}
|
||||
let disabledClasses = disabledClassesMap[themeVariant]
|
||||
|
||||
let sizeClasses = {
|
||||
sm: 'h-7 text-base px-2 rounded',
|
||||
md: 'h-8 text-base font-medium px-2.5 rounded',
|
||||
lg: 'h-10 text-lg font-medium px-3 rounded-md',
|
||||
xl: 'h-11.5 text-xl font-medium px-3.5 rounded-lg',
|
||||
'2xl': 'h-13 text-2xl font-medium px-3.5 rounded-xl',
|
||||
}[props.size]
|
||||
|
||||
if (isIconButton.value) {
|
||||
sizeClasses = {
|
||||
sm: 'h-7 w-7 rounded',
|
||||
md: 'h-8 w-8 rounded',
|
||||
lg: 'h-10 w-10 rounded-md',
|
||||
xl: 'h-11.5 w-11.5 rounded-lg',
|
||||
'2xl': 'h-13 w-13 rounded-xl',
|
||||
}[props.size]
|
||||
}
|
||||
|
||||
return [
|
||||
'inline-flex items-center justify-center gap-2 transition-colors focus:outline-none shrink-0',
|
||||
isDisabled.value ? disabledClasses : variantClasses,
|
||||
focusClasses,
|
||||
sizeClasses,
|
||||
]
|
||||
})
|
||||
|
||||
const slotClasses = computed(() => {
|
||||
let classes = {
|
||||
sm: 'h-4',
|
||||
md: 'h-4.5',
|
||||
lg: 'h-5',
|
||||
xl: 'h-6',
|
||||
'2xl': 'h-6',
|
||||
}[props.size]
|
||||
|
||||
return classes
|
||||
})
|
||||
|
||||
const isDisabled = computed(() => {
|
||||
return props.disabled || props.loading
|
||||
})
|
||||
|
||||
const ariaLabel = computed(() => {
|
||||
return isIconButton.value ? props.label : null
|
||||
})
|
||||
|
||||
const isIconButton = computed(() => {
|
||||
return props.icon || slots.icon || hasLucideIconInDefaultSlot.value
|
||||
})
|
||||
|
||||
const hasLucideIconInDefaultSlot = computed(() => {
|
||||
if (!slots.default) return false
|
||||
|
||||
const slotContent = slots.default()
|
||||
if (!Array.isArray(slotContent)) return false
|
||||
// if the slot contains only one element and it's a lucide icon
|
||||
// render it as an icon button
|
||||
let firstVNode = slotContent[0]
|
||||
if (
|
||||
typeof firstVNode.type?.name == 'string' &&
|
||||
firstVNode.type?.name?.startsWith('lucide-')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const handleClick = () => {
|
||||
if (props.route) {
|
||||
return router.push(props.route)
|
||||
} else if (props.link) {
|
||||
return window.open(props.link, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
const rootRef = ref()
|
||||
defineExpose({ rootRef })
|
||||
</script>
|
||||
2
src/components/Button/index.ts
Normal file
2
src/components/Button/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { default as Button } from './Button.vue'
|
||||
export type { ButtonProps } from './types'
|
||||
24
src/components/Button/types.ts
Normal file
24
src/components/Button/types.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { type RouterLinkProps } from 'vue-router'
|
||||
import { type Component } from 'vue'
|
||||
|
||||
type Theme = 'gray' | 'blue' | 'green' | 'red'
|
||||
type Size = 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
||||
type Variant = 'solid' | 'subtle' | 'outline' | 'ghost'
|
||||
|
||||
export interface ButtonProps {
|
||||
theme?: Theme
|
||||
size?: Size
|
||||
variant?: Variant
|
||||
label?: string
|
||||
icon?: string | Component
|
||||
iconLeft?: string | Component
|
||||
iconRight?: string | Component
|
||||
tooltip?: string
|
||||
loading?: boolean
|
||||
loadingText?: string
|
||||
disabled?: boolean
|
||||
route?: RouterLinkProps['to']
|
||||
link?: string
|
||||
}
|
||||
|
||||
export type ThemeVariant = `${Theme}-${Variant}`
|
||||
197
src/components/Calendar/Calendar.story.md
Normal file
197
src/components/Calendar/Calendar.story.md
Normal file
@ -0,0 +1,197 @@
|
||||
## Props
|
||||
|
||||
### events
|
||||
|
||||
`events` is an array of objects, where each object consists of a single event. By default the value of events props is an empty array `[]`
|
||||
|
||||
A single event can be of 2 types
|
||||
|
||||
1. Timed Event, event which has a start and end time.
|
||||
|
||||
Event object looks like:
|
||||
|
||||
{
|
||||
title: 'English by Ryan Mathew',
|
||||
participant: 'Ryan Mathew',
|
||||
id: 'EDU-CSH-2024-00091',
|
||||
venue: 'CNF-ROOM-2024-00001',
|
||||
fromDate: '2024-07-08 16:30:00',
|
||||
toDate: '2024-07-08 17:30:00',
|
||||
color: 'green',
|
||||
},
|
||||
|
||||
- `id, fromDate, toDate,` keys are mandatory for this kind of event.
|
||||
|
||||
- `id` should be unique for each event.
|
||||
|
||||
- `fromDate` and `toDate` should be in the above format or can be a date object. Currently Single date event is created and it is taken from fromDate. In future multiple day events will also be introduced.
|
||||
|
||||
- `color` can be from a list of
|
||||
|
||||
`["blue","green", "red", "orange", "yellow", "teal", "violet", "cyan", "purple", "pink", "amber"].`
|
||||
|
||||
If any other color is provided then the default color will be "green".
|
||||
|
||||
2. Full Day Event
|
||||
|
||||
The object for this kind of event looks like:
|
||||
|
||||
{
|
||||
title: 'Zoom Meet with Sheldon',
|
||||
participant: 'Sheldon',
|
||||
id: '#htrht42',
|
||||
venue: 'Google Meet',
|
||||
fromDate: '2024-07-21 00:00:00',
|
||||
toDate: '2024-07-21 23:59:59',
|
||||
color: 'amber',
|
||||
isFullDay: true,
|
||||
},
|
||||
|
||||
- `id, isFullDay,fromDate, toDate` keys are mandatory for this kind of event.
|
||||
|
||||
### config
|
||||
|
||||
`config` is an object which consists of the following keys:
|
||||
|
||||
{
|
||||
|
||||
disableModes: [],
|
||||
defaultMode: 'Month',
|
||||
isEditMode: false,
|
||||
eventIcons: {},
|
||||
hourHeight: 50,
|
||||
enableShortcuts: true,
|
||||
showIcon: true,
|
||||
|
||||
}
|
||||
|
||||
- `disableModes`: This is an array of strings which consists of the modes which are to be disabled. The default value is an empty array. If the value is ['Day'] then the Day mode will be disabled and the user will not be able to switch to the Day mode. Only the Week and Month mode will be available.
|
||||
|
||||
- `defaultMode`: This is the default mode in which the calendar will be loaded. The default value is 'Month'. It can be one of the following values:
|
||||
|
||||
- Day
|
||||
- Week
|
||||
- Month
|
||||
|
||||
- `isEditMode`: This is a boolean value which is used to enable or disable the edit mode. The default value is false. So by default the calendar is in read-only mode. If it is set to true then the user can perform actions like adding, editing, and deleting the events.
|
||||
|
||||
- `eventIcons`: This is an object which consists of the icons which are to be displayed for the events. The default value is an empty object. This objects changes the icon of the event on the basis of the type of event. If the type of event is not present in the object then the default icon will be displayed. `type_of_event` property can be set in the event object to display the icon. The icon of the event will be taken from this object. So if your event has an event type of "Call" then the icon will be taken from this object. The object should be in the following format:
|
||||
|
||||
{
|
||||
'type_of_event1': 'icon_component1',
|
||||
'type_of_event2': 'icon_component2',
|
||||
}
|
||||
|
||||
e.g.
|
||||
|
||||
{
|
||||
'Call': <CallIcon />,
|
||||
'Meeting': <MeetingIcon />,
|
||||
}
|
||||
|
||||
- `hourHeight`: The height of each cell below the full day events cell. This value is in pixel, by default the value is `50px`.
|
||||
- `enableShortcuts`: Boolean value which determines whether shortcuts will be enabled or not. By default the value is true i.e. shortcuts will be enabled, can be disabled by setting it to false, currently the calendar supports shortcuts like
|
||||
|
||||
- Navigating between views: By pressing M(monthly), W(weekly), D(daily), you can navigate between the views.
|
||||
- Navigating inside a view: By pressing right arrow(→) or left arrow(←) key on your keyboard you can navigate inside a view.
|
||||
- Delete: When an event is focused you can press the delete button to delete the event.
|
||||
|
||||
- `showIcon`: Boolean value which determines whether the icon will be displayed or not in the Event. By default the value is true i.e. icon will be displayed, can be disabled by setting it to false.
|
||||
|
||||
- Many functional props are also there which will be discussed in the below sections.
|
||||
|
||||
## Custom API Integrations
|
||||
|
||||
To integrate the calendar with your API, you need to pass the following functions as emits to the Calendar component:
|
||||
|
||||
- create: This function is called when a new event is created from the UI. The first argument in the function is the new event created.
|
||||
|
||||
- update: This function is called when an existing event is updated. The first argument in the function is an object which has the updated event.
|
||||
|
||||
- delete: This function is called when an existing event is deleted. The first argument in the function is the id of the event to be deleted.
|
||||
|
||||
e.g.
|
||||
|
||||
<Calendar
|
||||
:config="config"
|
||||
:events="events"
|
||||
@create="(event) => console.log('createEvent', event)"
|
||||
@update="(event) => console.log('updateEvent', event)"
|
||||
@delete="(eventID) => console.log('deleteEvent', eventID)"
|
||||
/>
|
||||
|
||||
In these functions, you can set up your API calls to create, update, and delete events.
|
||||
|
||||
## Calendar Click Events
|
||||
|
||||
1. Single Click any event to get additional data of the event via Popover, edit/delete the event from the popover.
|
||||
|
||||
2. Double Click any cell to create a new event.
|
||||
|
||||
3. Double Click any Event to edit an event. When an event is updated the update function is called (mentioned above)
|
||||
|
||||
## Custom Calendar Click Events
|
||||
|
||||
If you wish to handle clicks on your own, the Calendar provides 3 functions to handle clicks via props.
|
||||
|
||||
<Calendar
|
||||
:config="config"
|
||||
:events="events"
|
||||
:onClick="(event) => console.log('onClick', event)"
|
||||
:onDblClick="(event) => console.log('onDblClick', event)"
|
||||
:onCellClick="(data) => console.log('onCellClick', data)"
|
||||
/>
|
||||
|
||||
`Note: while using custom click events, the create, update & delete prop functions will not be triggered.`
|
||||
|
||||
- `onClick`: The function is triggered when an event is clicked. In the callback function you receive an argument which is an object and it looks like this:
|
||||
|
||||
{
|
||||
e:MouseEvent,
|
||||
calendarEvent: Object
|
||||
}
|
||||
|
||||
- e: this key represent the MouseEvent.
|
||||
- calendarEvent: This key is an object, the object of calendarEvent is displayed above
|
||||
|
||||
- `onDblClick`: The function is triggered when an event is double clicked. In the callback function you receive an argument which is an object and it looks like this:
|
||||
|
||||
{
|
||||
e:MouseEvent,
|
||||
calendarEvent: Object
|
||||
}
|
||||
|
||||
- e: this key represent the MouseEvent.
|
||||
- calendarEvent: This key is an object, the object of calendarEvent is displayed above
|
||||
|
||||
- `onCellClick`: The function is triggered when a cell is clicked. In the callback function you receive an argument which is an object and it looks like this:
|
||||
|
||||
{
|
||||
e:MouseEvent,
|
||||
date: Date Object,
|
||||
time: String,
|
||||
view: "Day" | "Week" | "Month"
|
||||
}
|
||||
|
||||
- e: this key represent the MouseEvent.
|
||||
- date: Date Object, which has the date of the cell which was clicked.
|
||||
- time: String, ranges from "00:00" to "23:00", where the cell was clicked in the grid that time value will be displayed over here. (Note, this will be empty in Month view)
|
||||
- view: String, shows the view in which the event was triggered.
|
||||
|
||||
## Custom Header
|
||||
|
||||
If you wish to create your own header instead of the default header, you can use a slot called "header". It can be implemented in a way shown in the story with variant "custom-header".
|
||||
|
||||
```
|
||||
<template #header="{ currentMonthYear, enabledModes, activeView, decrement, increment, updateActiveView }">
|
||||
</template>
|
||||
```
|
||||
|
||||
The header slot return 6 props:
|
||||
|
||||
1. `currentMonthYear`: String, returns the current month and the current year. e.g. August, 2024
|
||||
2. `enabledModes`: Array of Objects, returns the enabled modes which can be configured using "config" prop.
|
||||
3. `decrement`: Function, returns a function which allows user to navigate to previous month/week/day in the current view.
|
||||
4. `increment`: Function, returns a function which allows user to navigate to next month/week/day in the current view.
|
||||
5. `activeView`: String, returns the current view of the calendar. This can be used as modelValue.
|
||||
6. `updateActiveView`: Function, this function can be used to update the current view of the calendar.
|
||||
190
src/components/Calendar/Calendar.story.vue
Normal file
190
src/components/Calendar/Calendar.story.vue
Normal file
@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<Story :layout="{ type: 'grid', width: '100%' }">
|
||||
<Variant title="default">
|
||||
<div class="flex h-screen flex-col overflow-hidden p-5">
|
||||
<Calendar
|
||||
:config="config"
|
||||
:events="events"
|
||||
@create="(event) => logEvent('createEvent', event)"
|
||||
@update="(event) => logEvent('updateEvent', event)"
|
||||
@delete="(eventID) => logEvent('deleteEvent', eventID)"
|
||||
>
|
||||
</Calendar>
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="custom-header">
|
||||
<div class="flex h-screen flex-col overflow-hidden p-5">
|
||||
<Calendar
|
||||
:config="config"
|
||||
:events="events"
|
||||
@create="(event) => logEvent('createEvent', event)"
|
||||
@update="(event) => logEvent('updateEvent', event)"
|
||||
@delete="(eventID) => logEvent('deleteEvent', eventID)"
|
||||
>
|
||||
<template #header="headerProps">
|
||||
<!-- Custom header demonstrating full control over layout while keeping design aligned -->
|
||||
<div class="mb-2 flex items-center justify-between gap-3">
|
||||
<!-- Left cluster: date picker + nav + title -->
|
||||
<div class="flex items-center gap-2">
|
||||
<DatePicker
|
||||
:modelValue="headerProps.selectedMonthDate"
|
||||
@update:modelValue="
|
||||
(val) => headerProps.onMonthYearChange(val)
|
||||
"
|
||||
:clearable="false"
|
||||
>
|
||||
<template #target="{ togglePopover }">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="text-lg font-medium text-ink-gray-7"
|
||||
:label="headerProps.currentMonthYear"
|
||||
iconRight="chevron-down"
|
||||
@click="togglePopover"
|
||||
/>
|
||||
</template>
|
||||
</DatePicker>
|
||||
</div>
|
||||
<!-- Right cluster: view mode select -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="chevron-left"
|
||||
@click="headerProps.decrement"
|
||||
/>
|
||||
<Button
|
||||
label="Today"
|
||||
variant="ghost"
|
||||
@click="headerProps.setCalendarDate()"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="chevron-right"
|
||||
@click="headerProps.increment"
|
||||
/>
|
||||
</div>
|
||||
<div class="">
|
||||
<Select
|
||||
class="!w-20"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
:options="headerProps.enabledModes"
|
||||
:modelValue="headerProps.activeView"
|
||||
@update:modelValue="(v) => headerProps.updateActiveView(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Calendar>
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="custom-click-events">
|
||||
<div class="flex h-screen flex-col overflow-hidden p-5">
|
||||
<Calendar
|
||||
:config="config"
|
||||
:events="events"
|
||||
:onClick="(event) => logEvent('onClick', event)"
|
||||
:onDblClick="(event) => logEvent('onDblClick', event)"
|
||||
:onCellClick="(data) => logEvent('onCellClick', data)"
|
||||
>
|
||||
</Calendar>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import Calendar from './Calendar.vue'
|
||||
import { Select } from '../Select'
|
||||
import DatePicker from '../DatePicker/DatePicker.vue'
|
||||
import { Button } from '../Button'
|
||||
|
||||
const config = {
|
||||
defaultMode: 'Month',
|
||||
isEditMode: true,
|
||||
eventIcons: {},
|
||||
allowCustomClickEvents: true,
|
||||
enableShortcuts: false,
|
||||
}
|
||||
|
||||
function getCurrentMonthYear() {
|
||||
const date = new Date()
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
|
||||
return `${year}-${month}`
|
||||
}
|
||||
|
||||
const currentMonthYear = getCurrentMonthYear()
|
||||
|
||||
const events = ref([
|
||||
{
|
||||
title: 'English by Ryan Mathew',
|
||||
participant: 'Ryan Mathew',
|
||||
id: 'EDU-CSH-2024-00091',
|
||||
venue: 'CNF-ROOM-2024-00001',
|
||||
fromDate: currentMonthYear + '-02', //can be a date object
|
||||
toDate: currentMonthYear + '-02',
|
||||
fromTime: '16:30',
|
||||
toTime: '17:30',
|
||||
color: 'violet',
|
||||
},
|
||||
{
|
||||
title: 'English by Ryan Mathew',
|
||||
participant: 'Ryan Mathew',
|
||||
id: 'EDU-CSH-2024-00092',
|
||||
venue: 'CNF-ROOM-2024-00002',
|
||||
fromDate: currentMonthYear + '-04',
|
||||
toDate: currentMonthYear + '-04',
|
||||
fromTime: '13:30',
|
||||
toTime: '17:30',
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
title: 'English by Sheldon',
|
||||
participant: 'Sheldon',
|
||||
id: 'EDU-CSH-2024-00093',
|
||||
venue: 'CNF-ROOM-2024-00001',
|
||||
fromDate: currentMonthYear + '-16',
|
||||
toDate: currentMonthYear + '-16',
|
||||
fromTime: '10:30',
|
||||
toTime: '11:30',
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
title: 'English by Ryan Mathew',
|
||||
participant: 'Ryan Mathew',
|
||||
id: 'EDU-CSH-2024-00094',
|
||||
venue: 'CNF-ROOM-2024-00001',
|
||||
fromDate: currentMonthYear + '-21',
|
||||
toDate: currentMonthYear + '-21',
|
||||
fromTime: '16:30',
|
||||
toTime: '17:30',
|
||||
color: 'red',
|
||||
},
|
||||
{
|
||||
title: 'Google Meet with John ',
|
||||
participant: 'John',
|
||||
id: '#htrht41',
|
||||
venue: 'Google Meet',
|
||||
fromDate: currentMonthYear + '-11',
|
||||
toDate: currentMonthYear + '-11',
|
||||
fromTime: '00:00',
|
||||
toTime: '02:00',
|
||||
color: 'amber',
|
||||
isFullDay: true,
|
||||
},
|
||||
{
|
||||
title: 'Zoom Meet with Sheldon',
|
||||
participant: 'Sheldon',
|
||||
id: '#htrht42',
|
||||
venue: 'Google Meet',
|
||||
fromDate: currentMonthYear + '-07',
|
||||
toDate: currentMonthYear + '-07',
|
||||
fromTime: '00:00',
|
||||
toTime: '02:00',
|
||||
color: 'amber',
|
||||
isFullDay: true,
|
||||
},
|
||||
])
|
||||
</script>
|
||||
<style></style>
|
||||
618
src/components/Calendar/Calendar.vue
Normal file
618
src/components/Calendar/Calendar.vue
Normal file
@ -0,0 +1,618 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<slot
|
||||
name="header"
|
||||
v-bind="{
|
||||
currentMonthYear,
|
||||
currentYear,
|
||||
currentMonth,
|
||||
enabledModes,
|
||||
activeView,
|
||||
decrement,
|
||||
increment,
|
||||
updateActiveView,
|
||||
setCalendarDate,
|
||||
onMonthYearChange,
|
||||
selectedMonthDate,
|
||||
}"
|
||||
>
|
||||
<div class="mb-2 flex justify-between">
|
||||
<!-- left side -->
|
||||
<!-- Year, Month -->
|
||||
<div class="flex items-center">
|
||||
<DatePicker
|
||||
:modelValue="selectedMonthDate"
|
||||
@update:modelValue="(val) => onMonthYearChange(val)"
|
||||
:clearable="false"
|
||||
>
|
||||
<template #target="{ togglePopover }">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="text-lg font-medium text-ink-gray-7"
|
||||
:label="currentMonthYear"
|
||||
iconRight="chevron-down"
|
||||
@click="togglePopover"
|
||||
/>
|
||||
</template>
|
||||
</DatePicker>
|
||||
</div>
|
||||
<!-- right side -->
|
||||
<!-- actions buttons for calendar -->
|
||||
<div class="flex gap-x-1">
|
||||
<!-- Increment and Decrement Button-->
|
||||
|
||||
<Button @click="decrement" variant="ghost" icon="chevron-left" />
|
||||
<Button label="Today" @click="setCalendarDate()" variant="ghost" />
|
||||
<Button @click="increment" variant="ghost" icon="chevron-right" />
|
||||
|
||||
<!-- View change button, default is months or can be set via props! -->
|
||||
<TabButtons
|
||||
:buttons="enabledModes"
|
||||
class="ml-2"
|
||||
v-model="activeView"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<CalendarMonthly
|
||||
v-if="activeView === 'Month'"
|
||||
:events="events"
|
||||
:currentMonth="currentMonth"
|
||||
:currentMonthDates="currentMonthDates"
|
||||
:config="overrideConfig"
|
||||
@setCurrentDate="(d) => updateCurrentDate(d)"
|
||||
/>
|
||||
|
||||
<CalendarWeekly
|
||||
v-else-if="activeView === 'Week'"
|
||||
:events="events"
|
||||
:weeklyDates="datesInWeeks[week]"
|
||||
:config="overrideConfig"
|
||||
/>
|
||||
|
||||
<CalendarDaily
|
||||
v-else-if="activeView === 'Day'"
|
||||
:events="events"
|
||||
:current-date="selectedDay"
|
||||
:config="overrideConfig"
|
||||
>
|
||||
<template #header="{ parseDateWithDay, currentDate, fullDay }">
|
||||
<slot
|
||||
name="daily-header"
|
||||
v-bind="{ parseDateWithDay, currentDate, fullDay }"
|
||||
/>
|
||||
</template>
|
||||
</CalendarDaily>
|
||||
|
||||
<NewEventModal
|
||||
v-if="showEventModal"
|
||||
v-model="showEventModal"
|
||||
:event="newEvent"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
computed,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
provide,
|
||||
ref,
|
||||
watch,
|
||||
nextTick,
|
||||
} from 'vue'
|
||||
import { Button } from '../Button'
|
||||
import { TabButtons } from '../TabButtons'
|
||||
import {
|
||||
getCalendarDates,
|
||||
monthList,
|
||||
handleSeconds,
|
||||
formatMonthYear,
|
||||
getWeekMonthParts,
|
||||
} from './calendarUtils'
|
||||
import { dayjs } from '../../utils/dayjs'
|
||||
import DayIcon from './Icon/DayIcon.vue'
|
||||
import WeekIcon from './Icon/WeekIcon.vue'
|
||||
import MonthIcon from './Icon/MonthIcon.vue'
|
||||
import DatePicker from '../DatePicker/DatePicker.vue'
|
||||
import CalendarMonthly from './CalendarMonthly.vue'
|
||||
import CalendarWeekly from './CalendarWeekly.vue'
|
||||
import CalendarDaily from './CalendarDaily.vue'
|
||||
import NewEventModal from './NewEventModal.vue'
|
||||
import useEventModal from './composables/useEventModal'
|
||||
|
||||
const props = defineProps({
|
||||
events: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: [],
|
||||
},
|
||||
config: {
|
||||
type: Object,
|
||||
},
|
||||
onClick: {
|
||||
type: Function,
|
||||
required: false,
|
||||
},
|
||||
onDblClick: {
|
||||
type: Function,
|
||||
required: false,
|
||||
},
|
||||
onCellClick: {
|
||||
type: Function,
|
||||
required: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['create', 'update', 'delete'])
|
||||
|
||||
const defaultConfig = {
|
||||
scrollToHour: 15,
|
||||
disableModes: [],
|
||||
defaultMode: 'Month',
|
||||
isEditMode: false,
|
||||
eventIcons: {},
|
||||
hourHeight: 50,
|
||||
enableShortcuts: true,
|
||||
showIcon: true,
|
||||
timeFormat: '12h',
|
||||
weekends: ['sunday'],
|
||||
}
|
||||
|
||||
const overrideConfig = { ...defaultConfig, ...props.config }
|
||||
let activeView = ref(overrideConfig.defaultMode)
|
||||
|
||||
function updateActiveView(value, d, isPreviousMonth, isNextMonth) {
|
||||
activeView.value = value
|
||||
if (value == 'Day' && d) {
|
||||
date.value = findIndexOfDate(d)
|
||||
isPreviousMonth && decrementMonth()
|
||||
isNextMonth && incrementMonth()
|
||||
}
|
||||
}
|
||||
|
||||
const selectedMonthDate = ref(dayjs().format('YYYY-MM-DD'))
|
||||
|
||||
function onMonthYearChange(val = '') {
|
||||
const d = dayjs(val)
|
||||
selectedMonthDate.value = d.format('YYYY-MM-DD')
|
||||
|
||||
setCalendarDate(selectedMonthDate.value)
|
||||
}
|
||||
|
||||
function syncSelectedMonth(year, month) {
|
||||
// Keep same day if possible; otherwise clamp to last day
|
||||
if (typeof year === 'number' && typeof month === 'number') {
|
||||
const currentDay = dayjs(selectedMonthDate.value).date()
|
||||
|
||||
let tentative = dayjs(
|
||||
`${year}-${String(month + 1).padStart(2, '0')}-01`,
|
||||
).date(currentDay)
|
||||
|
||||
if (tentative.month() !== month) {
|
||||
// overflowed into next month, use last day of target month
|
||||
tentative = tentative.startOf('month').month(month).endOf('month')
|
||||
}
|
||||
|
||||
selectedMonthDate.value = tentative.format('YYYY-MM-DD')
|
||||
}
|
||||
}
|
||||
|
||||
// shortcuts for changing the active view and navigating through the calendar
|
||||
onMounted(() => {
|
||||
if (!overrideConfig.enableShortcuts) return
|
||||
window.addEventListener('keydown', handleShortcuts)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleShortcuts)
|
||||
})
|
||||
function handleShortcuts(e) {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) {
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key.toLowerCase() === 'm') {
|
||||
activeView.value = 'Month'
|
||||
}
|
||||
if (e.key.toLowerCase() === 'w') {
|
||||
activeView.value = 'Week'
|
||||
}
|
||||
if (e.key.toLowerCase() === 'd') {
|
||||
activeView.value = 'Day'
|
||||
}
|
||||
if (e.key.toLowerCase() === 't') {
|
||||
setCalendarDate()
|
||||
}
|
||||
if (e.key === 'ArrowLeft') {
|
||||
decrement()
|
||||
}
|
||||
if (e.key === 'ArrowRight') {
|
||||
increment()
|
||||
}
|
||||
}
|
||||
|
||||
provide('activeView', activeView)
|
||||
provide('config', overrideConfig)
|
||||
|
||||
const parseEvents = computed(() => {
|
||||
return (
|
||||
props.events?.map((event) => {
|
||||
const { fromDate, toDate, fromTime, toTime, ...rest } = event
|
||||
const date = fromDate
|
||||
const fromDateTime = fromDate + ' ' + fromTime
|
||||
const toDateTime = toDate + ' ' + toTime
|
||||
|
||||
return {
|
||||
...rest,
|
||||
date,
|
||||
fromDateTime,
|
||||
toDateTime,
|
||||
fromDate,
|
||||
toDate,
|
||||
fromTime,
|
||||
toTime,
|
||||
}
|
||||
}) || []
|
||||
)
|
||||
})
|
||||
const events = ref(parseEvents.value)
|
||||
|
||||
watch(
|
||||
() => props.events,
|
||||
() => reloadEvents(),
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
function reloadEvents() {
|
||||
events.value = parseEvents.value
|
||||
}
|
||||
|
||||
events.value.forEach((event) => {
|
||||
if (!event.fromTime || !event.toTime) return
|
||||
|
||||
event.fromTime = handleSeconds(event.fromTime)
|
||||
event.toTime = handleSeconds(event.toTime)
|
||||
})
|
||||
|
||||
const { showEventModal, newEvent, openNewEventModal } = useEventModal()
|
||||
|
||||
provide('calendarActions', {
|
||||
createNewEvent,
|
||||
updateEventState,
|
||||
deleteEvent,
|
||||
handleCellClick,
|
||||
updateActiveView,
|
||||
props,
|
||||
})
|
||||
|
||||
// CRUD actions on an event
|
||||
function createNewEvent(event) {
|
||||
events.value.push(event)
|
||||
event.fromDateTime = event.fromDate + ' ' + event.fromTime
|
||||
event.toDateTime = event.toDate + ' ' + event.toTime
|
||||
emit('create', event)
|
||||
}
|
||||
|
||||
function updateEventState(event) {
|
||||
const eventID = event.id
|
||||
let eventIndex = events.value.findIndex((e) => e.id === eventID)
|
||||
event.fromDateTime = event.fromDate + ' ' + event.fromTime
|
||||
event.toDateTime = event.toDate + ' ' + event.toTime
|
||||
events.value[eventIndex] = event
|
||||
emit('update', event)
|
||||
}
|
||||
|
||||
function deleteEvent(eventID) {
|
||||
// Delete event
|
||||
const eventIndex = events.value.findIndex((event) => event.id === eventID)
|
||||
events.value.splice(eventIndex, 1)
|
||||
emit('delete', eventID)
|
||||
}
|
||||
|
||||
function openModal(data) {
|
||||
const { e, view, date, time, isFullDay } = data
|
||||
const config = overrideConfig.isEditMode
|
||||
openNewEventModal(e, view, date, config, time, isFullDay)
|
||||
}
|
||||
|
||||
function handleCellClick(e, date, time = '', isFullDay = false) {
|
||||
const data = {
|
||||
e,
|
||||
view: activeView.value,
|
||||
date,
|
||||
time,
|
||||
isFullDay,
|
||||
}
|
||||
|
||||
if (props.onCellClick) {
|
||||
props.onCellClick(data)
|
||||
return
|
||||
}
|
||||
openModal(data)
|
||||
}
|
||||
|
||||
// Calendar View Options
|
||||
const actionOptions = [
|
||||
{ label: 'Day', value: 'Day', iconLeft: DayIcon },
|
||||
{ label: 'Week', value: 'Week', iconLeft: WeekIcon },
|
||||
{ label: 'Month', value: 'Month', iconLeft: MonthIcon },
|
||||
]
|
||||
let enabledModes = actionOptions.filter(
|
||||
(mode) => !overrideConfig.disableModes.includes(mode.value),
|
||||
)
|
||||
|
||||
let currentYear = ref(new Date().getFullYear())
|
||||
let currentMonth = ref(new Date().getMonth())
|
||||
let currentDate = ref(new Date())
|
||||
|
||||
let currentMonthDates = computed(() => {
|
||||
let dates = getCalendarDates(currentMonth.value, currentYear.value)
|
||||
return dates
|
||||
})
|
||||
|
||||
let datesInWeeks = computed(() => {
|
||||
let dates = [...currentMonthDates.value]
|
||||
let datesInWeeks = []
|
||||
while (dates.length) {
|
||||
let week = dates.splice(0, 7)
|
||||
datesInWeeks.push(week)
|
||||
}
|
||||
return datesInWeeks
|
||||
})
|
||||
|
||||
function findCurrentWeek(date) {
|
||||
return datesInWeeks.value.findIndex((week) =>
|
||||
week.find(
|
||||
(d) =>
|
||||
new Date(d).toLocaleDateString().split('T')[0] ===
|
||||
new Date(date).toLocaleDateString().split('T')[0],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
let week = ref(findCurrentWeek(currentDate.value))
|
||||
|
||||
let date = ref(
|
||||
currentMonthDates.value.findIndex(
|
||||
(d) => new Date(d).toDateString() === currentDate.value.toDateString(),
|
||||
),
|
||||
)
|
||||
let selectedDay = computed(() => currentMonthDates.value[date.value])
|
||||
|
||||
function updateCurrentDate(d) {
|
||||
activeView.value = 'Day'
|
||||
date.value = findIndexOfDate(d)
|
||||
week.value = findCurrentWeek(d)
|
||||
}
|
||||
|
||||
function increment() {
|
||||
incrementClickEvents[activeView.value]()
|
||||
syncSelectedMonth(currentYear.value, currentMonth.value)
|
||||
}
|
||||
|
||||
function decrement() {
|
||||
decrementClickEvents[activeView.value]()
|
||||
syncSelectedMonth(currentYear.value, currentMonth.value)
|
||||
}
|
||||
|
||||
const incrementClickEvents = {
|
||||
Month: incrementMonth,
|
||||
Week: incrementWeek,
|
||||
Day: incrementDay,
|
||||
}
|
||||
|
||||
const decrementClickEvents = {
|
||||
Month: decrementMonth,
|
||||
Week: decrementWeek,
|
||||
Day: decrementDay,
|
||||
}
|
||||
|
||||
function incrementMonth() {
|
||||
currentMonth.value++
|
||||
if (currentMonth.value > 11) {
|
||||
currentMonth.value = 0
|
||||
currentYear.value++
|
||||
}
|
||||
// After month changes, recompute month dates and reset to first in-month day
|
||||
date.value = findFirstDateOfMonth(currentMonth.value, currentYear.value)
|
||||
week.value = findCurrentWeek(currentMonthDates.value[date.value])
|
||||
}
|
||||
|
||||
function decrementMonth() {
|
||||
if (currentMonth.value === 0) {
|
||||
currentMonth.value = 11
|
||||
currentYear.value--
|
||||
} else {
|
||||
currentMonth.value--
|
||||
}
|
||||
// After adjusting month/year, pick last in-month date and its week
|
||||
date.value = findLastDateOfMonth(currentMonth.value, currentYear.value)
|
||||
week.value = findCurrentWeek(currentMonthDates.value[date.value])
|
||||
}
|
||||
|
||||
function incrementWeek() {
|
||||
const nextWeek = week.value + 1 // target next week index
|
||||
|
||||
// Case 1: still within current grid
|
||||
if (nextWeek < datesInWeeks.value.length) {
|
||||
week.value = nextWeek
|
||||
const weekDates = datesInWeeks.value[week.value]
|
||||
const spansNextMonth = weekDates.some(
|
||||
(d) => d.getMonth() !== currentMonth.value,
|
||||
) // overlap into next month
|
||||
if (spansNextMonth) {
|
||||
// cross boundary -> advance month
|
||||
incrementMonth()
|
||||
week.value = 0 // first week row of new month
|
||||
const firstWeekDates = datesInWeeks.value[0]
|
||||
const day = firstInMonth(firstWeekDates, currentMonth.value) // first in-month day
|
||||
date.value = findIndexOfDate(day)
|
||||
return
|
||||
}
|
||||
const day = firstInMonth(weekDates, currentMonth.value) // first in-month day in target week
|
||||
date.value = findIndexOfDate(day)
|
||||
return
|
||||
}
|
||||
|
||||
// Case 2: overflow -> next month first week
|
||||
incrementMonth()
|
||||
week.value = 0
|
||||
const firstWeekDates = datesInWeeks.value[0]
|
||||
const day = firstInMonth(firstWeekDates, currentMonth.value) // first valid in-month day
|
||||
date.value = findIndexOfDate(day)
|
||||
}
|
||||
|
||||
function decrementWeek() {
|
||||
const prevWeek = week.value - 1 // target previous week index
|
||||
|
||||
// Case 1: still within current grid
|
||||
if (prevWeek >= 0) {
|
||||
week.value = prevWeek
|
||||
const weekDates = datesInWeeks.value[week.value]
|
||||
const spansPrevMonth = weekDates.some(
|
||||
(d) => d.getMonth() !== currentMonth.value,
|
||||
) // overlap into previous month
|
||||
if (spansPrevMonth) {
|
||||
// cross boundary -> go to previous month
|
||||
decrementMonth()
|
||||
week.value = datesInWeeks.value.length - 1 // last week row of new month
|
||||
const targetWeekDates = datesInWeeks.value[week.value]
|
||||
const day = firstInMonth(targetWeekDates, currentMonth.value) // first day actually in that month
|
||||
date.value = findIndexOfDate(day)
|
||||
return
|
||||
}
|
||||
const day = firstInMonth(weekDates, currentMonth.value) // first in-month day in target week
|
||||
date.value = findIndexOfDate(day)
|
||||
return
|
||||
}
|
||||
|
||||
// Case 2: underflow -> jump to previous month
|
||||
decrementMonth()
|
||||
let targetIndex = datesInWeeks.value.length - 1 // start at last row
|
||||
const lastWeekDates = datesInWeeks.value[targetIndex]
|
||||
const hasNextMonthDates = lastWeekDates.some(
|
||||
(d) => d.getMonth() !== currentMonth.value,
|
||||
) // overlap into next month
|
||||
if (hasNextMonthDates && targetIndex > 0) {
|
||||
targetIndex = targetIndex - 1 // skip overlap row
|
||||
}
|
||||
week.value = targetIndex
|
||||
const targetWeekDates = datesInWeeks.value[week.value]
|
||||
const day = firstInMonth(targetWeekDates, currentMonth.value) // first valid in-month day
|
||||
date.value = findIndexOfDate(day)
|
||||
}
|
||||
|
||||
function incrementDay() {
|
||||
date.value++
|
||||
if (
|
||||
date.value > currentMonthDates.value.length - 1 ||
|
||||
!isCurrentMonthDate(currentMonthDates.value[date.value])
|
||||
) {
|
||||
incrementMonth()
|
||||
}
|
||||
}
|
||||
|
||||
function decrementDay() {
|
||||
date.value--
|
||||
if (
|
||||
date.value < 0 ||
|
||||
!isCurrentMonthDate(currentMonthDates.value[date.value])
|
||||
) {
|
||||
decrementMonth()
|
||||
}
|
||||
}
|
||||
|
||||
function firstInMonth(weekDates, month) {
|
||||
return weekDates.find((d) => d.getMonth() === month) || weekDates[0]
|
||||
}
|
||||
|
||||
function findLastDateOfMonth(month, year) {
|
||||
let inputDate = new Date(year, month + 1, 0)
|
||||
let lastDateIndex = currentMonthDates.value.findIndex(
|
||||
(date) => new Date(date).toDateString() === inputDate.toDateString(),
|
||||
)
|
||||
return lastDateIndex
|
||||
}
|
||||
|
||||
function findFirstDateOfMonth(month, year) {
|
||||
let inputDate = new Date(year, month, 1)
|
||||
let firstDateIndex = currentMonthDates.value.findIndex(
|
||||
(date) => new Date(date).toDateString() === inputDate.toDateString(),
|
||||
)
|
||||
return firstDateIndex
|
||||
}
|
||||
|
||||
function findIndexOfDate(date) {
|
||||
return currentMonthDates.value.findIndex(
|
||||
(d) => new Date(d).toDateString() === new Date(date).toDateString(),
|
||||
)
|
||||
}
|
||||
|
||||
const currentMonthYear = computed(() => {
|
||||
if (activeView.value === 'Day') {
|
||||
const dayDate = currentMonthDates.value[date.value]
|
||||
if (dayDate) {
|
||||
return dayjs(dayDate).format('ddd, D MMM YYYY')
|
||||
}
|
||||
}
|
||||
|
||||
// Non-week views or empty week fallback
|
||||
if (activeView.value !== 'Week')
|
||||
return formatMonthYear(currentMonth.value, currentYear.value)
|
||||
|
||||
const weekDates = datesInWeeks.value[week.value] || []
|
||||
if (!weekDates.length)
|
||||
return formatMonthYear(currentMonth.value, currentYear.value)
|
||||
|
||||
const parts = getWeekMonthParts(weekDates)
|
||||
if (parts.length === 1) return formatMonthYear(parts[0].month, parts[0].year)
|
||||
|
||||
const short = monthList.map((m) => m.slice(0, 3))
|
||||
const first = parts[0]
|
||||
const last = parts[parts.length - 1]
|
||||
|
||||
return first.year === last.year
|
||||
? `${short[first.month]} - ${short[last.month]} ${first.year}` // Same year span
|
||||
: `${short[first.month]} ${first.year} - ${short[last.month]} ${last.year}` // Cross-year span
|
||||
})
|
||||
|
||||
function isCurrentMonthDate(date) {
|
||||
date = new Date(date)
|
||||
return date.getMonth() === currentMonth.value
|
||||
}
|
||||
|
||||
function setCalendarDate(d) {
|
||||
const dt = d ? new Date(d) : new Date()
|
||||
if (dt.toString() === 'Invalid Date') return
|
||||
currentYear.value = dt.getFullYear()
|
||||
currentMonth.value = dt.getMonth()
|
||||
currentDate.value = dt
|
||||
// Wait for reactive recalculations of month dates
|
||||
nextTick(() => {
|
||||
week.value = findCurrentWeek(dt)
|
||||
const idx = findIndexOfDate(dt)
|
||||
if (idx >= 0) {
|
||||
date.value = idx
|
||||
} else {
|
||||
// Fallback: first date of month
|
||||
date.value = findFirstDateOfMonth(currentMonth.value, currentYear.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
reloadEvents,
|
||||
currentMonthYear,
|
||||
currentYear,
|
||||
currentMonth,
|
||||
enabledModes,
|
||||
activeView,
|
||||
decrement,
|
||||
increment,
|
||||
updateActiveView,
|
||||
setCalendarDate,
|
||||
onMonthYearChange,
|
||||
selectedMonthDate,
|
||||
})
|
||||
</script>
|
||||
170
src/components/Calendar/CalendarDaily.vue
Normal file
170
src/components/Calendar/CalendarDaily.vue
Normal file
@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 overflow-y-auto">
|
||||
<!-- Full day events -->
|
||||
<div
|
||||
class="flex shrink-0 h-fit"
|
||||
:class="[config.noBorder ? 'border-t-[1px]' : 'border-[1px] border-b-0']"
|
||||
>
|
||||
<div
|
||||
class="flex justify-center items-start pt-[3px] w-20 text-base text-ink-gray-6 text-center"
|
||||
>
|
||||
<component
|
||||
:is="showCollapsable ? Button : 'div'"
|
||||
:class="{ '!pl-1.5 pr-1 py-1 !gap-1': showCollapsable }"
|
||||
variant="ghost"
|
||||
:iconRight="
|
||||
showCollapsable ? (isCollapsed ? 'chevron-down' : 'chevron-up') : ''
|
||||
"
|
||||
@click="showCollapsable && (isCollapsed = !isCollapsed)"
|
||||
>
|
||||
<div class="text-sm text-ink-gray-6 h-7 inline-flex items-center">
|
||||
All day
|
||||
</div>
|
||||
</component>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-wrap gap-1 py-1 w-full overflow-hidden"
|
||||
:data-date-attr="currentDate"
|
||||
@click.prevent="
|
||||
calendarActions.handleCellClick($event, currentDate, '', true)
|
||||
"
|
||||
>
|
||||
<CalendarEvent
|
||||
v-for="(calendarEvent, idx) in !showCollapsable || !isCollapsed
|
||||
? dayFullDayEvents
|
||||
: dayFullDayEvents.slice(0, 4)"
|
||||
class="w-[21%] cursor-pointer"
|
||||
:event="{ ...calendarEvent, idx }"
|
||||
:key="calendarEvent.id"
|
||||
:date="currentDate"
|
||||
@click.stop
|
||||
/>
|
||||
<Button
|
||||
v-if="showCollapsable && isCollapsed && dayFullDayEvents.length > 4"
|
||||
:label="dayFullDayEvents.length - 4 + ' more'"
|
||||
variant="ghost"
|
||||
class="w-fit text-sm !h-6 !justify-start cursor-pointer"
|
||||
@click.stop="isCollapsed = false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-full overflow-hidden">
|
||||
<div
|
||||
class="flex h-full w-full overflow-scroll border-outline-gray-1"
|
||||
:class="[
|
||||
config.noBorder ? 'border-t-[1px]' : 'border-[1px] border-r-0',
|
||||
]"
|
||||
ref="gridRef"
|
||||
>
|
||||
<!-- Left column -->
|
||||
<div class="grid h-full w-20 grid-cols-1">
|
||||
<span
|
||||
v-for="time in 24"
|
||||
class="flex h-[72px] items-end justify-center text-center text-sm font-normal text-ink-gray-5"
|
||||
:style="{ height: `${hourHeight}px` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Calendar Grid / Right Column -->
|
||||
<div class="grid h-full w-full grid-cols-1 pb-2">
|
||||
<div
|
||||
class="calendar-column relative border-l-[1px] border-outline-gray-1"
|
||||
:class="[config.noBorder ? '' : ' border-r-[1px]']"
|
||||
>
|
||||
<!-- Day Grid -->
|
||||
<div
|
||||
class="relative flex text-ink-gray-8"
|
||||
v-for="(time, i) in timeArray"
|
||||
:key="time"
|
||||
:data-time-attr="i == 0 ? '' : time"
|
||||
@click="
|
||||
calendarActions.handleCellClick($event, currentDate, time)
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="w-full border-outline-gray-1"
|
||||
:class="i !== timeArray.length - 1 && 'border-b-[1px]'"
|
||||
:style="{ height: `${hourHeight}px` }"
|
||||
/>
|
||||
</div>
|
||||
<CalendarEvent
|
||||
v-for="(calendarEvent, idx) in timedEvents[
|
||||
parseDate(currentDate)
|
||||
]"
|
||||
class="absolute mb-2 cursor-pointer"
|
||||
:event="calendarEvent"
|
||||
:key="calendarEvent.id"
|
||||
:date="currentDate"
|
||||
/>
|
||||
<!-- Current time Marker -->
|
||||
<CalendarTimeMarker :date="currentDate" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||
import CalendarEvent from './CalendarEvent.vue'
|
||||
import CalendarTimeMarker from './CalendarTimeMarker.vue'
|
||||
import { Button } from '../Button'
|
||||
import {
|
||||
parseDate,
|
||||
parseDateWithDay,
|
||||
twelveHoursFormat,
|
||||
twentyFourHoursFormat,
|
||||
} from './calendarUtils'
|
||||
import useCalendarData from './composables/useCalendarData'
|
||||
|
||||
const props = defineProps({
|
||||
events: {
|
||||
type: Object,
|
||||
required: false,
|
||||
},
|
||||
config: {
|
||||
type: Object,
|
||||
},
|
||||
currentDate: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
const timedEvents = computed(
|
||||
() => useCalendarData(props.events).timedEvents.value,
|
||||
)
|
||||
const fullDayEvents = computed(
|
||||
() => useCalendarData(props.events).fullDayEvents.value,
|
||||
)
|
||||
const gridRef = ref(null)
|
||||
const hourHeight = props.config.hourHeight
|
||||
const minuteHeight = hourHeight / 60
|
||||
|
||||
const showCollapsable = ref(false)
|
||||
const isCollapsed = ref(true)
|
||||
const dayFullDayEvents = computed(
|
||||
() => fullDayEvents.value?.[parseDate(props.currentDate)] || [],
|
||||
)
|
||||
|
||||
function updateFullDayEventsState() {
|
||||
// Show collapsible if more than 4 events
|
||||
showCollapsable.value = dayFullDayEvents.value.length > 4
|
||||
if (!showCollapsable.value) {
|
||||
isCollapsed.value = true
|
||||
}
|
||||
}
|
||||
|
||||
watch(dayFullDayEvents, updateFullDayEventsState, { immediate: true })
|
||||
|
||||
const timeArray =
|
||||
props.config.timeFormat == '24h' ? twentyFourHoursFormat : twelveHoursFormat
|
||||
|
||||
onMounted(() => {
|
||||
const currentHour = new Date().getHours()
|
||||
const scrollToHour = props.config.scrollToHour || currentHour
|
||||
gridRef.value.scrollBy(0, scrollToHour * 60 * minuteHeight - 10)
|
||||
})
|
||||
|
||||
const calendarActions = inject('calendarActions')
|
||||
</script>
|
||||
662
src/components/Calendar/CalendarEvent.vue
Normal file
662
src/components/Calendar/CalendarEvent.vue
Normal file
@ -0,0 +1,662 @@
|
||||
<template>
|
||||
<!-- Weekly and Daily Event Template -->
|
||||
<div
|
||||
class="event min-h-6 mx-px shadow rounded transition-all duration-75 shrink-0"
|
||||
ref="eventRef"
|
||||
v-if="activeView !== 'Month'"
|
||||
v-bind="$attrs"
|
||||
:class="[
|
||||
opened && '!z-20 drop-shadow-xl',
|
||||
activeEvent == (props.event?.id || props.event?.name) && 'active',
|
||||
]"
|
||||
:style="[setEventStyles, eventBgStyle]"
|
||||
@dblclick.prevent="handleEventEdit($event)"
|
||||
@click.prevent="handleEventClick($event)"
|
||||
v-on="{
|
||||
mousedown: config.isEditMode && handleRepositionMouseDown,
|
||||
}"
|
||||
>
|
||||
<div class="flex gap-1.5 h-full p-[5px]" :class="isPastEvent && 'past'">
|
||||
<div
|
||||
v-if="props.event.fromTime"
|
||||
class="event-border h-full w-[2px] rounded shrink-0"
|
||||
:style="eventBorderStyle"
|
||||
/>
|
||||
<div
|
||||
class="relative flex h-full select-none items-start gap-2 overflow-hidden"
|
||||
>
|
||||
<div v-if="config.showIcon && eventIcons[props.event.type]">
|
||||
<component
|
||||
v-if="eventIcons[props.event.type]"
|
||||
:is="eventIcons[props.event.type]"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex w-fit flex-col gap-0.5 overflow-hidden">
|
||||
<p
|
||||
ref="eventTitleRef"
|
||||
class="text-sm font-medium event-title"
|
||||
:class="lineClampClass"
|
||||
>
|
||||
{{ props.event.title || '(No title)' }}
|
||||
</p>
|
||||
<p
|
||||
ref="eventTimeRef"
|
||||
class="text-xs font-normal event-subtitle"
|
||||
v-if="!props.event.isFullDay"
|
||||
>
|
||||
{{
|
||||
formattedDuration(
|
||||
updatedEvent.fromTime,
|
||||
updatedEvent.toTime,
|
||||
config.timeFormat,
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="config.isEditMode && !event.isFullDay"
|
||||
class="absolute -bottom-1 h-3 w-full cursor-ns-resize"
|
||||
ref="resize"
|
||||
@mousedown="handleResizeMouseDown"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Monthly Event Template -->
|
||||
<div
|
||||
v-else
|
||||
class="event flex gap-1.5 min-h-6 mx-px rounded p-[5px] transition-all duration-75"
|
||||
:class="[
|
||||
activeEvent == (props.event?.id || props.event?.name) && 'active',
|
||||
isPastEvent && 'past',
|
||||
]"
|
||||
ref="eventRef"
|
||||
v-bind="$attrs"
|
||||
@dblclick.prevent="handleEventEdit($event)"
|
||||
@click.stop="handleEventClick($event)"
|
||||
:style="eventBgStyle"
|
||||
>
|
||||
<div
|
||||
v-if="props.event.fromTime"
|
||||
class="event-border w-[2px] rounded shrink-0"
|
||||
:style="eventBorderStyle"
|
||||
/>
|
||||
<div
|
||||
class="relative flex h-full select-none items-start gap-2 overflow-hidden"
|
||||
>
|
||||
<div v-if="config.showIcon && eventIcons[props.event.type]">
|
||||
<component
|
||||
v-if="eventIcons[props.event.type]"
|
||||
:is="eventIcons[props.event.type]"
|
||||
class="h-4 w-4 text-black"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex w-fit flex-col text-start overflow-hidden whitespace-nowrap"
|
||||
>
|
||||
<p class="text-sm font-medium truncate">
|
||||
{{ props.event.title || 'New Event' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="floating"
|
||||
:style="{ ...floatingStyles, zIndex: 100 }"
|
||||
v-if="opened"
|
||||
class="rounded shadow-xl"
|
||||
>
|
||||
<EventModalContent
|
||||
:calendarEvent="calendarEvent"
|
||||
:date="date"
|
||||
:isEditMode="config.isEditMode"
|
||||
@close="close"
|
||||
@edit="handleEventEdit"
|
||||
@delete="handleEventDelete"
|
||||
class="shadow-xl"
|
||||
/>
|
||||
</div>
|
||||
<NewEventModal v-model="showEventModal" :event="updatedEvent" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import EventModalContent from './EventModalContent.vue'
|
||||
import NewEventModal from './NewEventModal.vue'
|
||||
import { useFloating, shift, flip, offset, autoUpdate } from '@floating-ui/vue'
|
||||
import { activeEvent } from './composables/useCalendarData.js'
|
||||
|
||||
import {
|
||||
ref,
|
||||
inject,
|
||||
computed,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
watch,
|
||||
reactive,
|
||||
} from 'vue'
|
||||
|
||||
import {
|
||||
calculateMinutes,
|
||||
convertMinutesToHours,
|
||||
calculateDiff,
|
||||
parseDate,
|
||||
colorMap,
|
||||
colorMapDark,
|
||||
formattedDuration,
|
||||
} from './calendarUtils'
|
||||
|
||||
const props = defineProps({
|
||||
event: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
date: {
|
||||
type: Date,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const activeView = inject('activeView')
|
||||
const config = inject('config')
|
||||
const calendarActions = inject('calendarActions')
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
function handleClickOutside(e) {
|
||||
const insidePopover = floating.value && floating.value.contains(e.target)
|
||||
if (insidePopover) return
|
||||
const insideTarget = eventRef.value && eventRef.value.contains(e.target)
|
||||
if (insideTarget) return
|
||||
close()
|
||||
}
|
||||
|
||||
const calendarEvent = ref(props.event)
|
||||
|
||||
const updatedEvent = reactive({
|
||||
...props.event,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.event,
|
||||
(newVal) => {
|
||||
updatedEvent.fromTime = newVal.fromTime
|
||||
updatedEvent.toTime = newVal.toTime
|
||||
updatedEvent.fromDate = newVal.fromDate
|
||||
updatedEvent.toDate = newVal.toDate
|
||||
updatedEvent.fromDateTime = newVal.fromDate + ' ' + newVal.fromTime
|
||||
updatedEvent.toDateTime = newVal.toDate + ' ' + newVal.toTime
|
||||
calendarEvent.value = newVal
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
const eventIcons = config.eventIcons
|
||||
const minuteHeight = config.hourHeight / 60
|
||||
const height15Min = minuteHeight * 15
|
||||
|
||||
const state = reactive({
|
||||
xAxis: 0,
|
||||
yAxis: 0,
|
||||
})
|
||||
|
||||
const heightThreshold = 40
|
||||
const minimumHeight = 32.5
|
||||
const setEventStyles = computed(() => {
|
||||
if (props.event.isFullDay) {
|
||||
return {
|
||||
transform: `translate(${state.xAxis}px, ${state.yAxis}px)`,
|
||||
zIndex: isRepositioning.value ? 100 : props.event.idx + 1,
|
||||
}
|
||||
}
|
||||
|
||||
let diff = calculateDiff(
|
||||
calendarEvent.value.fromTime,
|
||||
calendarEvent.value.toTime,
|
||||
)
|
||||
let height = diff * minuteHeight
|
||||
if (height < heightThreshold) {
|
||||
height = minimumHeight
|
||||
}
|
||||
height += 'px'
|
||||
|
||||
let top = calculateMinutes(calendarEvent.value.fromTime) * minuteHeight
|
||||
|
||||
let hallNumber = calendarEvent.value.hallNumber
|
||||
let width =
|
||||
isResizing.value || isRepositioning.value
|
||||
? '100%'
|
||||
: `${93 - hallNumber * 20}%`
|
||||
let left =
|
||||
isResizing.value || isRepositioning.value ? '0' : `${hallNumber * 20}%`
|
||||
let zIndex =
|
||||
isResizing.value || isRepositioning.value
|
||||
? 100
|
||||
: (props.event.idx || 1) * hallNumber + 1
|
||||
|
||||
return {
|
||||
height,
|
||||
top: top + 'px',
|
||||
zIndex: zIndex,
|
||||
left,
|
||||
width,
|
||||
transform: `translate(${state.xAxis}px, ${state.yAxis}px)`,
|
||||
}
|
||||
})
|
||||
|
||||
const eventBgStyle = computed(() => {
|
||||
let _color = props.event.color || 'green'
|
||||
_color = color(_color)
|
||||
|
||||
return {
|
||||
'--bg': _color.bg,
|
||||
'--text': _color.text,
|
||||
'--subtext': _color.subtext,
|
||||
'--text-active': _color.textActive,
|
||||
'--subtext-active': _color.subtextActive,
|
||||
'--bg-hover': _color.bgHover,
|
||||
'--bg-active': _color.bgActive,
|
||||
}
|
||||
})
|
||||
|
||||
const eventBorderStyle = computed(() => {
|
||||
let _color = props.event.color || 'green'
|
||||
_color = color(_color)
|
||||
|
||||
return { '--border': _color.border, '--border-active': _color.borderActive }
|
||||
})
|
||||
|
||||
const getTheme = () => {
|
||||
const theme = document.documentElement.getAttribute('data-theme')
|
||||
|
||||
if (theme) return theme
|
||||
return document.documentElement.classList.contains('htw-dark')
|
||||
? 'dark'
|
||||
: 'light'
|
||||
}
|
||||
|
||||
function color(color) {
|
||||
let map = getTheme() === 'dark' ? colorMapDark : colorMap
|
||||
|
||||
if (!color?.startsWith('#')) {
|
||||
return map[color] || map['green']
|
||||
}
|
||||
|
||||
for (const value of Object.values(map)) {
|
||||
if (value.color === color) return value
|
||||
}
|
||||
|
||||
return map['green']
|
||||
}
|
||||
|
||||
const eventTitleRef = ref(null)
|
||||
const eventTimeRef = ref(null)
|
||||
|
||||
const lineClampClass = computed(() => {
|
||||
if (activeView.value === 'Month') return
|
||||
if (props.event.isFullDay) return 'line-clamp-1'
|
||||
if (!eventRef.value || !eventTitleRef.value || !eventTimeRef.value) return
|
||||
if (!props.event.fromTime && !props.event.toTime) return
|
||||
|
||||
const containerHeight = eventRef.value.clientHeight
|
||||
const subtitleHeight = eventTimeRef.value.offsetHeight
|
||||
const availableHeightForTitle = containerHeight - subtitleHeight - 8 // margin
|
||||
|
||||
const computedStyle = getComputedStyle(eventTitleRef.value)
|
||||
const lineHeight = parseFloat(computedStyle.lineHeight)
|
||||
|
||||
const maxLines = Math.max(1, Math.floor(availableHeightForTitle / lineHeight))
|
||||
|
||||
// Clamp between 1 and 6 lines (Tailwind supports line-clamp-1 to line-clamp-6 by default)
|
||||
const clampValue = Math.min(maxLines, 6)
|
||||
return `line-clamp-${clampValue}`
|
||||
})
|
||||
|
||||
const eventRef = ref(null)
|
||||
// Popover Element Config
|
||||
const floating = ref(null)
|
||||
const { floatingStyles } = useFloating(eventRef, floating, {
|
||||
placement: activeView.value === 'Day' ? 'top' : 'right',
|
||||
middleware: [offset(10), flip(), shift()],
|
||||
whileElementsMounted: autoUpdate,
|
||||
})
|
||||
|
||||
const opened = ref(false)
|
||||
const resize = ref(null)
|
||||
const isResizing = ref(false)
|
||||
const isRepositioning = ref(false)
|
||||
const isEventUpdated = ref(false)
|
||||
|
||||
function newEventEndTime(newHeight) {
|
||||
let newEndTime =
|
||||
parseFloat(newHeight) / minuteHeight +
|
||||
calculateMinutes(calendarEvent.value.fromTime)
|
||||
newEndTime = Math.floor(newEndTime)
|
||||
if (newEndTime > 1440) {
|
||||
newEndTime = 1440
|
||||
}
|
||||
return convertMinutesToHours(newEndTime)
|
||||
}
|
||||
|
||||
const preventClick = ref(false)
|
||||
function handleResizeMouseDown(e) {
|
||||
isResizing.value = true
|
||||
isRepositioning.value = false
|
||||
|
||||
let oldTime = calendarEvent.value.toTime
|
||||
window.addEventListener('mousemove', resize)
|
||||
window.addEventListener('mouseup', stopResize, { once: true })
|
||||
|
||||
function resize(e) {
|
||||
preventClick.value = true
|
||||
// difference between where mouse is and where event's top is, to find the new height
|
||||
let diffX = e.clientY - eventRef.value.getBoundingClientRect().top
|
||||
eventRef.value.style.height =
|
||||
Math.round(diffX / height15Min) * height15Min + 'px'
|
||||
|
||||
eventRef.value.style.width = '100%'
|
||||
updatedEvent.toTime = newEventEndTime(eventRef.value.style.height)
|
||||
calendarEvent.value.toTime = newEventEndTime(eventRef.value.style.height)
|
||||
}
|
||||
|
||||
function stopResize() {
|
||||
isResizing.value = false
|
||||
if (oldTime !== calendarEvent.value.toTime) {
|
||||
calendarActions.updateEventState(calendarEvent.value)
|
||||
}
|
||||
|
||||
window.removeEventListener('mousemove', resize)
|
||||
}
|
||||
}
|
||||
|
||||
function handleRepositionMouseDown(e) {
|
||||
e.preventDefault()
|
||||
let prevY = e.clientY
|
||||
const rect = eventRef.value.getBoundingClientRect()
|
||||
|
||||
if (isResizing.value) return
|
||||
|
||||
window.addEventListener('mousemove', mousemove)
|
||||
window.addEventListener('mouseup', mouseup)
|
||||
|
||||
function mousemove(e) {
|
||||
isRepositioning.value = true
|
||||
preventClick.value = true
|
||||
if (!eventRef.value) return
|
||||
close()
|
||||
eventRef.value.style.cursor = 'grabbing'
|
||||
|
||||
// handle movement between days
|
||||
if (activeView.value === 'Week') {
|
||||
handleHorizontalMovement(e.clientX, rect)
|
||||
}
|
||||
|
||||
// handle movement within the same day
|
||||
if (!props.event.isFullDay) handleVerticalMovement(e.clientY, prevY, rect)
|
||||
|
||||
if (
|
||||
calendarEvent.value.fromTime !== updatedEvent.fromTime ||
|
||||
calendarEvent.value.toTime !== updatedEvent.toTime
|
||||
) {
|
||||
isEventUpdated.value = true
|
||||
} else {
|
||||
isEventUpdated.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function mouseup(e) {
|
||||
e.preventDefault()
|
||||
isRepositioning.value = false
|
||||
if (!eventRef.value) return
|
||||
|
||||
eventRef.value.style.cursor = 'pointer'
|
||||
if (calendarEvent.value.isFullDay && activeView.value === 'Week') {
|
||||
eventRef.value.style.width = '90%'
|
||||
}
|
||||
if (calendarEvent.value.date !== updatedEvent.date) {
|
||||
isEventUpdated.value = true
|
||||
}
|
||||
if (isEventUpdated.value) {
|
||||
calendarEvent.value.date = updatedEvent.date
|
||||
calendarEvent.value.fromDate = updatedEvent.date
|
||||
calendarEvent.value.toDate = updatedEvent.date
|
||||
calendarEvent.value.fromDateTime =
|
||||
updatedEvent.date + ' ' + updatedEvent.fromTime
|
||||
calendarEvent.value.toDateTime =
|
||||
updatedEvent.date + ' ' + updatedEvent.toTime
|
||||
calendarEvent.value.fromTime = updatedEvent.fromTime
|
||||
calendarEvent.value.toTime = updatedEvent.toTime
|
||||
calendarActions.updateEventState(calendarEvent.value)
|
||||
isEventUpdated.value = false
|
||||
state.xAxis = 0
|
||||
state.yAxis = 0
|
||||
}
|
||||
|
||||
window.removeEventListener('mousemove', mousemove)
|
||||
window.removeEventListener('mouseup', mouseup)
|
||||
}
|
||||
}
|
||||
|
||||
function getDate(date, nextDate = 0) {
|
||||
let newDate = new Date(
|
||||
date.getFullYear(),
|
||||
date.getMonth(),
|
||||
date.getDate() + nextDate,
|
||||
)
|
||||
return newDate
|
||||
}
|
||||
|
||||
function handleHorizontalMovement(clientX, rect) {
|
||||
const currentDate = new Date(
|
||||
eventRef.value.parentNode.getAttribute('data-date-attr'),
|
||||
)
|
||||
|
||||
if (props.event.isFullDay) {
|
||||
eventRef.value.style.width = '100%'
|
||||
}
|
||||
|
||||
let eventWidth = eventRef.value.clientWidth
|
||||
let diff = Math.floor((clientX - rect.left) / eventWidth)
|
||||
|
||||
const leftBoundary = currentDate.getDay()
|
||||
const rightBoundary = 6 - currentDate.getDay()
|
||||
diff = handleHorizontalBoundary(diff, leftBoundary, rightBoundary)
|
||||
let xPos = Math.ceil(diff * eventWidth)
|
||||
state.xAxis = xPos
|
||||
updatedEvent.date = parseDate(getDate(currentDate, diff))
|
||||
}
|
||||
|
||||
function handleHorizontalBoundary(diff, leftBoundary, rightBoundary) {
|
||||
if (diff < -leftBoundary) {
|
||||
diff = -leftBoundary
|
||||
} else if (diff > rightBoundary) {
|
||||
diff = rightBoundary
|
||||
}
|
||||
return diff
|
||||
}
|
||||
|
||||
function handleVerticalMovement(clientY, prevY, rect) {
|
||||
let diffY = clientY - prevY
|
||||
|
||||
// handle boundaries for the calendar event
|
||||
let parentTop = eventRef.value.parentNode.getBoundingClientRect().top
|
||||
let parentBottom = eventRef.value.parentNode.getBoundingClientRect().bottom
|
||||
|
||||
// to prevent event from going above the top of the parent cell
|
||||
if (clientY < parentTop) {
|
||||
diffY = parentTop - rect.top
|
||||
}
|
||||
// to prevent event from going below the bottom of the parent cell
|
||||
if (clientY > parentBottom) {
|
||||
diffY = parentBottom - rect.bottom
|
||||
}
|
||||
|
||||
diffY = Math.round(diffY / height15Min) * height15Min
|
||||
state.yAxis = diffY
|
||||
|
||||
updatedEvent.fromTime = convertMinutesToHours(
|
||||
calculateMinutes(calendarEvent.value.fromTime) +
|
||||
Math.round(diffY / minuteHeight),
|
||||
)
|
||||
updatedEvent.toTime = convertMinutesToHours(
|
||||
calculateMinutes(calendarEvent.value.toTime) +
|
||||
Math.round(diffY / minuteHeight),
|
||||
)
|
||||
handleTimeConstraints()
|
||||
}
|
||||
|
||||
function handleTimeConstraints() {
|
||||
if (updatedEvent.fromTime < '00:00:00') {
|
||||
updatedEvent.fromTime = '00:00:00'
|
||||
}
|
||||
if (updatedEvent.fromTime > '24:00:00') {
|
||||
updatedEvent.fromTime = '24:00:00'
|
||||
}
|
||||
if (updatedEvent.toTime < '00:00:00') {
|
||||
updatedEvent.toTime = '00:00:00'
|
||||
}
|
||||
if (updatedEvent.toTime > '24:00:00') {
|
||||
updatedEvent.toTime = '24:00:00'
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = () => (opened.value = !opened.value)
|
||||
const close = () => (opened.value = false)
|
||||
|
||||
function handleDeleteShortcut(e) {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
opened.value = false
|
||||
handleEventDelete()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => opened.value,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
if (!config.isEditMode) return
|
||||
if (!config.enableShortcuts) return
|
||||
document.addEventListener('keydown', handleDeleteShortcut, { once: true })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
let clickTimer = null
|
||||
function handleEventClick(e) {
|
||||
// hack to prevent event modal from opening when resizing or repositioning
|
||||
if (preventClick.value) {
|
||||
preventClick.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// hack: timeout to see whether it's a double click or a single click
|
||||
if (e.detail === 1) {
|
||||
clickTimer = setTimeout(() => {
|
||||
calendarActions.props.onClick
|
||||
? calendarActions.props.onClick({
|
||||
e,
|
||||
calendarEvent: calendarEvent.value,
|
||||
})
|
||||
: toggle()
|
||||
}, 200)
|
||||
}
|
||||
}
|
||||
|
||||
const showEventModal = ref(false)
|
||||
function handleEventEdit(e = null) {
|
||||
e && (e.cancelBubble = true)
|
||||
// if it's a double click, clear the timeout
|
||||
clearTimeout(clickTimer)
|
||||
if (calendarActions.props.onDblClick) {
|
||||
calendarActions.props.onDblClick({
|
||||
e,
|
||||
calendarEvent: calendarEvent.value,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!config.isEditMode) return
|
||||
close()
|
||||
showEventModal.value = true
|
||||
}
|
||||
|
||||
function handleEventDelete() {
|
||||
calendarActions.deleteEvent(calendarEvent.value.id)
|
||||
close()
|
||||
}
|
||||
|
||||
const isPastEvent = computed(() => {
|
||||
try {
|
||||
// determine end date/time
|
||||
const endDateStr =
|
||||
calendarEvent.value.toDate ||
|
||||
calendarEvent.value.date ||
|
||||
calendarEvent.value.fromDate ||
|
||||
props.event.toDate ||
|
||||
props.event.date ||
|
||||
props.event.fromDate
|
||||
|
||||
if (!endDateStr) return false
|
||||
// If event has a toTime use it; else if full day, treat end as end of day; fallback 00:00:00
|
||||
let endTimeStr = '00:00:00'
|
||||
if (calendarEvent.value.isFullDay || props.event.isFullDay)
|
||||
endTimeStr = '23:59:59'
|
||||
else if (calendarEvent.value.toTime) endTimeStr = calendarEvent.value.toTime
|
||||
|
||||
const end = new Date(`${endDateStr}T${endTimeStr}`.replace(' ', 'T'))
|
||||
return end.getTime() < new Date().getTime()
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.event {
|
||||
background-color: var(--bg);
|
||||
}
|
||||
.event .event-title {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.event .event-subtitle {
|
||||
color: var(--subtext);
|
||||
}
|
||||
|
||||
.event .event-border {
|
||||
background-color: var(--border);
|
||||
}
|
||||
|
||||
.event.active {
|
||||
background-color: var(--bg-active);
|
||||
}
|
||||
|
||||
.event.active .event-title {
|
||||
color: var(--text-active, #fff);
|
||||
}
|
||||
|
||||
.event.active .event-subtitle {
|
||||
color: var(--subtext-active);
|
||||
}
|
||||
|
||||
.event.active .event-border {
|
||||
background-color: var(--border-active);
|
||||
}
|
||||
|
||||
.event:not(.active):hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
.event:not(.active) .past,
|
||||
.event.past:not(.active) {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
193
src/components/Calendar/CalendarMonthly.vue
Normal file
193
src/components/Calendar/CalendarMonthly.vue
Normal file
@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div class="flex flex-1 flex-col overflow-scroll">
|
||||
<!-- Day List -->
|
||||
<div class="grid w-full grid-cols-7">
|
||||
<span
|
||||
v-for="day in daysList"
|
||||
class="inline-flex items-center justify-center text-base text-ink-gray-6 h-8"
|
||||
>
|
||||
{{ day }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Date Grid -->
|
||||
<div
|
||||
class="grid w-full flex-1 grid-cols-7 border-outline-gray-1"
|
||||
:class="[
|
||||
currentMonthDates.length > 35 ? 'grid-rows-6' : 'grid-rows-5',
|
||||
!config.noBorder && 'border-[0.5px]',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
v-for="(date, i) in currentMonthDates"
|
||||
class="overflow-y-auto"
|
||||
:class="[
|
||||
config.noBorder ? 'border-l border-t border-0' : 'border-[0.5px]',
|
||||
config.noBorder && i % 7 === 0 && 'border-l-0',
|
||||
isWeekend(date, config) && 'bg-surface-gray-1',
|
||||
]"
|
||||
@dragover.prevent
|
||||
@drageneter.prevent
|
||||
@drop="onDrop($event, date)"
|
||||
@click="calendarActions.handleCellClick($event, date)"
|
||||
>
|
||||
<div
|
||||
class="flex justify-center font-normal"
|
||||
:class="isCurrentMonth(date) ? 'text-gray-700' : 'text-gray-200'"
|
||||
>
|
||||
<div
|
||||
class="flex gap-0.5 w-full flex-col items-center text-xs text-right"
|
||||
>
|
||||
<span
|
||||
class="z-10 w-full flex justify-between items-center"
|
||||
:class="[
|
||||
date.toDateString() === new Date().toDateString()
|
||||
? 'p-[3px] pb-0.5'
|
||||
: 'p-2',
|
||||
]"
|
||||
>
|
||||
<div></div>
|
||||
<div
|
||||
class="cursor-pointer"
|
||||
:class="[
|
||||
date.toDateString() === new Date().toDateString()
|
||||
? 'flex items-center justify-center bg-surface-gray-7 text-ink-white rounded size-[25px]'
|
||||
: 'bg-surface-white ',
|
||||
isCurrentMonth(date) ? 'text-ink-gray-6' : 'text-ink-gray-4',
|
||||
]"
|
||||
@click.stop="
|
||||
isCurrentMonth(date)
|
||||
? calendarActions.updateActiveView('Day', date)
|
||||
: calendarActions.updateActiveView(
|
||||
'Day',
|
||||
date,
|
||||
isPreviousMonth(date),
|
||||
isNextMonth(date),
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ date.getDate() }}
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<div
|
||||
class="flex w-full flex-col justify-between"
|
||||
v-if="timedEvents[parseDate(date)]?.length <= maxEventsInCell"
|
||||
>
|
||||
<CalendarEvent
|
||||
v-for="calendarEvent in timedEvents[parseDate(date)]"
|
||||
:event="calendarEvent"
|
||||
:date="date"
|
||||
class="z-10 mb-2 cursor-pointer"
|
||||
:key="calendarEvent.id"
|
||||
:draggable="config.isEditMode"
|
||||
@dragstart="onDragStart($event, calendarEvent.id)"
|
||||
@dragend="$event.target.style.opacity = '1'"
|
||||
@dragover.prevent
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex w-full flex-col justify-between">
|
||||
<ShowMoreCalendarEvent
|
||||
v-if="timedEvents[parseDate(date)]"
|
||||
class="z-10 cursor-pointer"
|
||||
:draggable="config.isEditMode"
|
||||
@dragstart="
|
||||
onDragStart($event, timedEvents[parseDate(date)][0].id)
|
||||
"
|
||||
@dragend="$event.target.style.opacity = '1'"
|
||||
@dragover.prevent
|
||||
:events="timedEvents[parseDate(date)]"
|
||||
:date="date"
|
||||
:totalEventsCount="timedEvents[parseDate(date)].length"
|
||||
@showMoreEvents="emit('setCurrentDate', date)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { daysList, parseDate, isWeekend } from './calendarUtils'
|
||||
import { inject } from 'vue'
|
||||
import CalendarEvent from './CalendarEvent.vue'
|
||||
import useCalendarData from './composables/useCalendarData'
|
||||
import { computed } from 'vue'
|
||||
import ShowMoreCalendarEvent from './ShowMoreCalendarEvent.vue'
|
||||
const props = defineProps({
|
||||
events: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
currentMonthDates: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
currentMonth: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
config: {
|
||||
type: Object,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['setCurrentDate'])
|
||||
|
||||
const timedEvents = computed(
|
||||
() => useCalendarData(props.events, 'Month').timedEvents.value,
|
||||
)
|
||||
|
||||
const maxEventsInCell = computed(() =>
|
||||
props.currentMonthDates.length > 35 ? 1 : 2,
|
||||
)
|
||||
|
||||
function isCurrentMonth(date) {
|
||||
return date.getMonth() === props.currentMonth
|
||||
}
|
||||
|
||||
function isPreviousMonth(date) {
|
||||
let previousMonth = false
|
||||
if (date.getMonth() === props.currentMonth - 1) {
|
||||
previousMonth = true
|
||||
}
|
||||
return previousMonth
|
||||
}
|
||||
|
||||
function isNextMonth(date) {
|
||||
let nextMonth = false
|
||||
if (date.getMonth() === props.currentMonth + 1) {
|
||||
nextMonth = true
|
||||
}
|
||||
return nextMonth
|
||||
}
|
||||
|
||||
const calendarActions = inject('calendarActions')
|
||||
|
||||
const onDragStart = (event, calendarEventID) => {
|
||||
if (!calendarEventID) return
|
||||
event.target.style.opacity = '0.5'
|
||||
event.target.style.cursor = 'move'
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData('calendarEventID', calendarEventID)
|
||||
}
|
||||
|
||||
const onDrop = (event, date) => {
|
||||
let calendarEventID = event.dataTransfer.getData('calendarEventID')
|
||||
if (!calendarEventID) return
|
||||
event.target.style.cursor = 'default'
|
||||
// if same date then return
|
||||
let e = props.events.find((e) => e.id === calendarEventID)
|
||||
if (parseDate(date) === e.date) return
|
||||
let calendarEvent = props.events.find((e) => e.id === calendarEventID)
|
||||
calendarEvent.date = parseDate(date)
|
||||
calendarEvent.fromDate = calendarEvent.date
|
||||
calendarEvent.toDate = calendarEvent.date
|
||||
calendarEvent.fromDateTime = calendarEvent.date + ' ' + calendarEvent.fromTime
|
||||
calendarEvent.toDateTime = calendarEvent.date + ' ' + calendarEvent.toTime
|
||||
calendarActions.updateEventState(calendarEvent)
|
||||
}
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user