initial commit
Some checks failed
Publish on NPM / publish (push) Has been cancelled
Build and Deploy Storybook / build (push) Has been cancelled
Tests / test (push) Has been cancelled

This commit is contained in:
jingrow 2025-10-24 00:40:30 +08:00
commit c7bac1a7a0
474 changed files with 51250 additions and 0 deletions

4
.git-blame-ignore-revs Normal file
View File

@ -0,0 +1,4 @@
# Ran prettier on source
# after adding tailwind plugin
54563d9deba9d95c1212c1be2217ce8fa181162b
8e07a190aff0eba2a3e072f9857a654dca6fa0e1

18
.github/workflows/publish.yml vendored Normal file
View 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
View 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
View 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
View 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
View File

@ -0,0 +1 @@
_

6
.postcssrc.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

27
.pre-commit-config.yaml Normal file
View 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
View File

@ -0,0 +1,6 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"proseWrap": "always"
}

14
404.html Normal file
View 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
View 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
View 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
View 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']
}
}

View 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`.

View 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 &ndash;
- [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

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

45
docs/components/alert.md Normal file
View 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 |

View 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
View 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
View 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
View 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.

View 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>
```

View 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
View 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.
![Dialog Slots](../assets/dialog-slots.png)
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
View 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
},
...
]
}
]
```

View 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 |

View 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` | |

View 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
View 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` |

View 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
View 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 |

View 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) |

View 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
View 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 |

View 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 |

View 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>
```

View 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>
```

View 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
```

View 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'
})
```

View 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
View 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
View 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
View 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
View 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

Binary file not shown.

BIN
jingrow-ui-square.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
jingrow-ui.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

1
jingrow-ui.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

View 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>

View 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
View 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
View File

@ -0,0 +1,4 @@
import { ref } from 'vue'
export const showHelpModal = ref(false)
export const minimize = ref(false)

View 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>

View File

@ -0,0 +1,3 @@
import { ref } from 'vue'
export const showHelpCenter = ref(false)

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

106
readme.md Normal file
View 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 58PM" 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>

View 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>

View File

@ -0,0 +1,2 @@
export { default as Alert } from './Alert.vue'
export type { AlertProps } from './types.ts'

View File

@ -0,0 +1,4 @@
export interface AlertProps {
title?: string
type?: 'warning'
}

View 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>

View 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>

View File

@ -0,0 +1,2 @@
export { default as Autocomplete } from './Autocomplete.vue'
export type { AutocompleteProps } from './types'

View 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
}
)

View 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>

View 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>

View File

@ -0,0 +1,2 @@
export { default as Avatar } from './Avatar.vue'
export type { AvatarProps } from './types'

View File

@ -0,0 +1,6 @@
export interface AvatarProps {
image?: string
label?: string
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl'
shape?: 'circle' | 'square'
}

View 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>

View 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>

View File

@ -0,0 +1,2 @@
export { default as Badge } from './Badge.vue'
export type { BadgeProps } from './types'

View 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
}

View 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>

View 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>

View File

@ -0,0 +1,2 @@
export { default as Breadcrumbs } from './Breadcrumbs.vue'
export type { BreadcrumbsProps } from './types'

View 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[]
}

View 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>

View 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>

View File

@ -0,0 +1,2 @@
export { default as Button } from './Button.vue'
export type { ButtonProps } from './types'

View 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}`

View 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.

View 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>

View 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>

View 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>

View 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>

View 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