@davidbirchall/core 1.0.6 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.storybook/main.ts +18 -0
- package/.storybook/preview.ts +14 -0
- package/package.json +1 -4
- package/src/components/Badge/Badge.stories.ts +147 -0
- package/src/components/Badge/Badge.test.ts +57 -0
- package/src/components/Badge/Badge.vue +79 -0
- package/src/components/Button/Button.stories.ts +80 -0
- package/src/components/Button/Button.test.ts +145 -0
- package/src/components/Button/Button.vue +108 -0
- package/src/components/Button/types.ts +4 -0
- package/src/components/Calendar/Calendar.stories.ts +261 -0
- package/src/components/Calendar/Calendar.test.ts +119 -0
- package/src/components/Calendar/Calendar.vue +528 -0
- package/src/components/Calendar/types.ts +20 -0
- package/src/components/Card/Card.stories.ts +88 -0
- package/src/components/Card/Card.test.ts +173 -0
- package/src/components/Card/Card.vue +59 -0
- package/{dist/Card/types.d.ts → src/components/Card/types.ts} +1 -1
- package/src/components/Checkbox/Checkbox.stories.ts +126 -0
- package/src/components/Checkbox/Checkbox.test.ts +155 -0
- package/src/components/Checkbox/Checkbox.vue +121 -0
- package/src/components/Checkbox/types.ts +7 -0
- package/src/components/DataTable/DataTable.stories.ts +156 -0
- package/src/components/DataTable/DataTable.test.ts +185 -0
- package/src/components/DataTable/DataTable.vue +177 -0
- package/src/components/DataTable/types.ts +12 -0
- package/src/components/DatePicker/DatePicker.stories.ts +172 -0
- package/src/components/DatePicker/DatePicker.test.ts +87 -0
- package/src/components/DatePicker/DatePicker.vue +302 -0
- package/src/components/Dropdown/Dropdown.stories.ts +231 -0
- package/src/components/Dropdown/Dropdown.vue +314 -0
- package/src/components/Dropdown/types.ts +14 -0
- package/src/components/EmptyState/EmptyState.stories.ts +189 -0
- package/src/components/EmptyState/EmptyState.vue +215 -0
- package/src/components/EmptyState/types.ts +8 -0
- package/src/components/ErrorSummary/ErrorSummary.vue +78 -0
- package/src/components/ErrorSummary/types.ts +4 -0
- package/src/components/FormGroup/FormGroup.stories.ts +264 -0
- package/src/components/FormGroup/FormGroup.test.ts +63 -0
- package/src/components/FormGroup/FormGroup.vue +58 -0
- package/src/components/Heading/Heading.stories.ts +121 -0
- package/src/components/Heading/Heading.test.ts +184 -0
- package/src/components/Heading/Heading.vue +95 -0
- package/src/components/Heading/types.ts +6 -0
- package/src/components/Input/Input.stories.ts +172 -0
- package/src/components/Input/Input.test.ts +213 -0
- package/src/components/Input/Input.vue +121 -0
- package/src/components/Input/types.ts +11 -0
- package/src/components/Modal/Modal.stories.ts +341 -0
- package/src/components/Modal/Modal.test.ts +99 -0
- package/src/components/Modal/Modal.vue +278 -0
- package/src/components/ProgressBar/ProgressBar.stories.ts +313 -0
- package/src/components/ProgressBar/ProgressBar.test.ts +98 -0
- package/src/components/ProgressBar/ProgressBar.vue +117 -0
- package/src/components/Select/Select.stories.ts +177 -0
- package/src/components/Select/Select.test.ts +225 -0
- package/src/components/Select/Select.vue +147 -0
- package/src/components/Select/types.ts +16 -0
- package/src/components/StatCard/StatCard.stories.ts +274 -0
- package/src/components/StatCard/StatCard.vue +226 -0
- package/src/components/StatCard/types.ts +12 -0
- package/src/components/Tag/Tag.stories.ts +78 -0
- package/src/components/Tag/Tag.test.ts +50 -0
- package/src/components/Tag/Tag.vue +71 -0
- package/src/components/Tag/types.ts +4 -0
- package/src/components/TextArea/TextArea.stories.ts +171 -0
- package/src/components/TextArea/TextArea.test.ts +202 -0
- package/src/components/TextArea/TextArea.vue +122 -0
- package/src/components/TextArea/types.ts +11 -0
- package/src/components/index.ts +5 -0
- package/src/test/setup.ts +1 -0
- package/src/vite-env.d.ts +6 -0
- package/tsconfig.json +29 -0
- package/vite.config.ts +33 -0
- package/vitest.config.ts +28 -0
- package/dist/Button/types.d.ts +0 -4
- package/dist/Calendar/types.d.ts +0 -22
- package/dist/Checkbox/types.d.ts +0 -7
- package/dist/DataTable/types.d.ts +0 -11
- package/dist/Dropdown/types.d.ts +0 -13
- package/dist/EmptyState/types.d.ts +0 -8
- package/dist/ErrorSummary/types.d.ts +0 -4
- package/dist/Heading/types.d.ts +0 -6
- package/dist/Input/types.d.ts +0 -11
- package/dist/Select/types.d.ts +0 -15
- package/dist/StatCard/types.d.ts +0 -12
- package/dist/Tag/types.d.ts +0 -4
- package/dist/TextArea/types.d.ts +0 -11
- package/dist/core.css +0 -1
- package/dist/core.js +0 -24
- package/dist/core.js.map +0 -1
- package/dist/core.umd.cjs +0 -2
- package/dist/core.umd.cjs.map +0 -1
- package/dist/index.d.ts +0 -2
- package/dist/package.json +0 -27
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/vue3'
|
|
2
|
+
import DataTable from './DataTable.vue'
|
|
3
|
+
import type { DataTableColumn } from './types'
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Components/DataTable',
|
|
7
|
+
component: DataTable,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
argTypes: {
|
|
10
|
+
hoverable: {
|
|
11
|
+
control: 'boolean',
|
|
12
|
+
description: 'Enable hover effect on rows'
|
|
13
|
+
},
|
|
14
|
+
emptyText: {
|
|
15
|
+
control: 'text',
|
|
16
|
+
description: 'Text to display when no data is available'
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
args: {
|
|
20
|
+
hoverable: true,
|
|
21
|
+
emptyText: 'No data available'
|
|
22
|
+
}
|
|
23
|
+
} satisfies Meta<typeof DataTable>
|
|
24
|
+
|
|
25
|
+
export default meta
|
|
26
|
+
type Story = StoryObj<typeof meta>
|
|
27
|
+
|
|
28
|
+
const sampleColumns: DataTableColumn[] = [
|
|
29
|
+
{ key: 'id', label: 'ID', sortable: true },
|
|
30
|
+
{ key: 'name', label: 'Name', sortable: true },
|
|
31
|
+
{ key: 'email', label: 'Email', sortable: true },
|
|
32
|
+
{ key: 'role', label: 'Role', sortable: false }
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
const sampleData = [
|
|
36
|
+
{ id: 1, name: 'John Doe', email: 'john@example.com', role: 'Admin' },
|
|
37
|
+
{ id: 2, name: 'Jane Smith', email: 'jane@example.com', role: 'User' },
|
|
38
|
+
{ id: 3, name: 'Bob Johnson', email: 'bob@example.com', role: 'Editor' },
|
|
39
|
+
{ id: 4, name: 'Alice Williams', email: 'alice@example.com', role: 'User' },
|
|
40
|
+
{ id: 5, name: 'Charlie Brown', email: 'charlie@example.com', role: 'Admin' }
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
export const Basic: Story = {
|
|
44
|
+
args: {
|
|
45
|
+
columns: sampleColumns,
|
|
46
|
+
data: sampleData
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const Empty: Story = {
|
|
51
|
+
args: {
|
|
52
|
+
columns: sampleColumns,
|
|
53
|
+
data: []
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const CustomEmptyText: Story = {
|
|
58
|
+
args: {
|
|
59
|
+
columns: sampleColumns,
|
|
60
|
+
data: [],
|
|
61
|
+
emptyText: 'No users found'
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const NoHover: Story = {
|
|
66
|
+
args: {
|
|
67
|
+
columns: sampleColumns,
|
|
68
|
+
data: sampleData,
|
|
69
|
+
hoverable: false
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const ProductTable: Story = {
|
|
74
|
+
args: {
|
|
75
|
+
columns: [
|
|
76
|
+
{ key: 'product', label: 'Product', sortable: true },
|
|
77
|
+
{ key: 'category', label: 'Category', sortable: true },
|
|
78
|
+
{ key: 'price', label: 'Price', sortable: true },
|
|
79
|
+
{ key: 'stock', label: 'Stock', sortable: true },
|
|
80
|
+
{ key: 'status', label: 'Status', sortable: false }
|
|
81
|
+
],
|
|
82
|
+
data: [
|
|
83
|
+
{ product: 'Laptop', category: 'Electronics', price: 999, stock: 45, status: 'In Stock' },
|
|
84
|
+
{ product: 'Mouse', category: 'Accessories', price: 25, stock: 120, status: 'In Stock' },
|
|
85
|
+
{ product: 'Keyboard', category: 'Accessories', price: 75, stock: 0, status: 'Out of Stock' },
|
|
86
|
+
{ product: 'Monitor', category: 'Electronics', price: 350, stock: 23, status: 'In Stock' },
|
|
87
|
+
{ product: 'Webcam', category: 'Electronics', price: 89, stock: 8, status: 'Low Stock' }
|
|
88
|
+
]
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const WithCustomSlots: Story = {
|
|
93
|
+
args: {
|
|
94
|
+
columns: [
|
|
95
|
+
{ key: 'name', label: 'Name', sortable: true },
|
|
96
|
+
{ key: 'status', label: 'Status', sortable: false },
|
|
97
|
+
{ key: 'actions', label: 'Actions', sortable: false }
|
|
98
|
+
],
|
|
99
|
+
data: [
|
|
100
|
+
{ name: 'John Doe', status: 'active', actions: '' },
|
|
101
|
+
{ name: 'Jane Smith', status: 'inactive', actions: '' },
|
|
102
|
+
{ name: 'Bob Johnson', status: 'active', actions: '' }
|
|
103
|
+
]
|
|
104
|
+
},
|
|
105
|
+
render: (args: any) => ({
|
|
106
|
+
components: { DataTable },
|
|
107
|
+
setup() {
|
|
108
|
+
return { args }
|
|
109
|
+
},
|
|
110
|
+
template: `
|
|
111
|
+
<DataTable v-bind="args">
|
|
112
|
+
<template #cell-status="{ value }">
|
|
113
|
+
<span :style="{
|
|
114
|
+
color: value === 'active' ? '#10b981' : '#ef4444',
|
|
115
|
+
fontWeight: 600
|
|
116
|
+
}">
|
|
117
|
+
{{ value.toUpperCase() }}
|
|
118
|
+
</span>
|
|
119
|
+
</template>
|
|
120
|
+
<template #cell-actions>
|
|
121
|
+
<button style="
|
|
122
|
+
padding: 0.25rem 0.75rem;
|
|
123
|
+
background: #3b82f6;
|
|
124
|
+
color: white;
|
|
125
|
+
border: none;
|
|
126
|
+
border-radius: 0.25rem;
|
|
127
|
+
cursor: pointer;
|
|
128
|
+
">
|
|
129
|
+
Edit
|
|
130
|
+
</button>
|
|
131
|
+
</template>
|
|
132
|
+
</DataTable>
|
|
133
|
+
`
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const LargeDataset: Story = {
|
|
138
|
+
args: {
|
|
139
|
+
columns: [
|
|
140
|
+
{ key: 'id', label: 'ID', sortable: true },
|
|
141
|
+
{ key: 'firstName', label: 'First Name', sortable: true },
|
|
142
|
+
{ key: 'lastName', label: 'Last Name', sortable: true },
|
|
143
|
+
{ key: 'email', label: 'Email', sortable: true },
|
|
144
|
+
{ key: 'department', label: 'Department', sortable: true },
|
|
145
|
+
{ key: 'salary', label: 'Salary', sortable: true }
|
|
146
|
+
],
|
|
147
|
+
data: Array.from({ length: 50 }, (_, i) => ({
|
|
148
|
+
id: i + 1,
|
|
149
|
+
firstName: ['John', 'Jane', 'Bob', 'Alice', 'Charlie'][i % 5],
|
|
150
|
+
lastName: ['Doe', 'Smith', 'Johnson', 'Williams', 'Brown'][i % 5],
|
|
151
|
+
email: `user${i + 1}@example.com`,
|
|
152
|
+
department: ['Engineering', 'Marketing', 'Sales', 'HR', 'Finance'][i % 5],
|
|
153
|
+
salary: 50000 + (i * 1000)
|
|
154
|
+
}))
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/vue'
|
|
3
|
+
import userEvent from '@testing-library/user-event'
|
|
4
|
+
import DataTable from './DataTable.vue'
|
|
5
|
+
import type { DataTableColumn } from './types'
|
|
6
|
+
|
|
7
|
+
describe('DataTable', () => {
|
|
8
|
+
const columns: DataTableColumn[] = [
|
|
9
|
+
{ key: 'name', label: 'Name', sortable: true },
|
|
10
|
+
{ key: 'age', label: 'Age', sortable: true },
|
|
11
|
+
{ key: 'email', label: 'Email' }
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
const data = [
|
|
15
|
+
{ name: 'John Doe', age: 30, email: 'john@example.com' },
|
|
16
|
+
{ name: 'Jane Smith', age: 25, email: 'jane@example.com' },
|
|
17
|
+
{ name: 'Bob Johnson', age: 35, email: 'bob@example.com' }
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
describe('rendering', () => {
|
|
21
|
+
it('renders table headers', () => {
|
|
22
|
+
render(DataTable, {
|
|
23
|
+
props: {
|
|
24
|
+
columns,
|
|
25
|
+
data
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
expect(screen.getByText('Name')).toBeInTheDocument()
|
|
30
|
+
expect(screen.getByText('Age')).toBeInTheDocument()
|
|
31
|
+
expect(screen.getByText('Email')).toBeInTheDocument()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('renders table data', () => {
|
|
35
|
+
render(DataTable, {
|
|
36
|
+
props: {
|
|
37
|
+
columns,
|
|
38
|
+
data
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
expect(screen.getByText('John Doe')).toBeInTheDocument()
|
|
43
|
+
expect(screen.getByText('30')).toBeInTheDocument()
|
|
44
|
+
expect(screen.getByText('john@example.com')).toBeInTheDocument()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('renders all rows', () => {
|
|
48
|
+
const { container } = render(DataTable, {
|
|
49
|
+
props: {
|
|
50
|
+
columns,
|
|
51
|
+
data
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const rows = container.querySelectorAll('tbody tr')
|
|
56
|
+
expect(rows).toHaveLength(3)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('shows empty text when no data', () => {
|
|
60
|
+
render(DataTable, {
|
|
61
|
+
props: {
|
|
62
|
+
columns,
|
|
63
|
+
data: []
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
expect(screen.getByText('No data available')).toBeInTheDocument()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('shows custom empty text', () => {
|
|
71
|
+
render(DataTable, {
|
|
72
|
+
props: {
|
|
73
|
+
columns,
|
|
74
|
+
data: [],
|
|
75
|
+
emptyText: 'Custom empty message'
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
expect(screen.getByText('Custom empty message')).toBeInTheDocument()
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('sorting', () => {
|
|
84
|
+
it('sorts data in ascending order when clicking sortable column', async () => {
|
|
85
|
+
const user = userEvent.setup()
|
|
86
|
+
const { container } = render(DataTable, {
|
|
87
|
+
props: {
|
|
88
|
+
columns,
|
|
89
|
+
data
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
const nameHeader = screen.getByText('Name').closest('th')
|
|
94
|
+
await user.click(nameHeader!)
|
|
95
|
+
|
|
96
|
+
const cells = container.querySelectorAll('tbody td:first-child')
|
|
97
|
+
expect(cells[0]).toHaveTextContent('Bob Johnson')
|
|
98
|
+
expect(cells[1]).toHaveTextContent('Jane Smith')
|
|
99
|
+
expect(cells[2]).toHaveTextContent('John Doe')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('sorts data in descending order when clicking sortable column twice', async () => {
|
|
103
|
+
const user = userEvent.setup()
|
|
104
|
+
const { container } = render(DataTable, {
|
|
105
|
+
props: {
|
|
106
|
+
columns,
|
|
107
|
+
data
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const nameHeader = screen.getByText('Name').closest('th')
|
|
112
|
+
await user.click(nameHeader!)
|
|
113
|
+
await user.click(nameHeader!)
|
|
114
|
+
|
|
115
|
+
const cells = container.querySelectorAll('tbody td:first-child')
|
|
116
|
+
expect(cells[0]).toHaveTextContent('John Doe')
|
|
117
|
+
expect(cells[1]).toHaveTextContent('Jane Smith')
|
|
118
|
+
expect(cells[2]).toHaveTextContent('Bob Johnson')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('sorts numeric columns correctly', async () => {
|
|
122
|
+
const user = userEvent.setup()
|
|
123
|
+
const { container } = render(DataTable, {
|
|
124
|
+
props: {
|
|
125
|
+
columns,
|
|
126
|
+
data
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
const ageHeader = screen.getByText('Age').closest('th')
|
|
131
|
+
await user.click(ageHeader!)
|
|
132
|
+
|
|
133
|
+
const cells = container.querySelectorAll('tbody td:nth-child(2)')
|
|
134
|
+
expect(cells[0]).toHaveTextContent('25')
|
|
135
|
+
expect(cells[1]).toHaveTextContent('30')
|
|
136
|
+
expect(cells[2]).toHaveTextContent('35')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('does not sort non-sortable columns', async () => {
|
|
140
|
+
const user = userEvent.setup()
|
|
141
|
+
const { container } = render(DataTable, {
|
|
142
|
+
props: {
|
|
143
|
+
columns,
|
|
144
|
+
data
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
const emailHeader = screen.getByText('Email').closest('th')
|
|
149
|
+
await user.click(emailHeader!)
|
|
150
|
+
|
|
151
|
+
// Data should remain in original order
|
|
152
|
+
const cells = container.querySelectorAll('tbody td:first-child')
|
|
153
|
+
expect(cells[0]).toHaveTextContent('John Doe')
|
|
154
|
+
expect(cells[1]).toHaveTextContent('Jane Smith')
|
|
155
|
+
expect(cells[2]).toHaveTextContent('Bob Johnson')
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
describe('hoverable', () => {
|
|
160
|
+
it('applies hoverable class by default', () => {
|
|
161
|
+
const { container } = render(DataTable, {
|
|
162
|
+
props: {
|
|
163
|
+
columns,
|
|
164
|
+
data
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
const firstRow = container.querySelector('tbody tr')
|
|
169
|
+
expect(firstRow).toHaveClass('data-table__row--hoverable')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('does not apply hoverable class when disabled', () => {
|
|
173
|
+
const { container } = render(DataTable, {
|
|
174
|
+
props: {
|
|
175
|
+
columns,
|
|
176
|
+
data,
|
|
177
|
+
hoverable: false
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
const firstRow = container.querySelector('tbody tr')
|
|
182
|
+
expect(firstRow).not.toHaveClass('data-table__row--hoverable')
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
})
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="data-table">
|
|
3
|
+
<div class="data-table__wrapper">
|
|
4
|
+
<table class="data-table__table">
|
|
5
|
+
<thead class="data-table__head">
|
|
6
|
+
<tr>
|
|
7
|
+
<th
|
|
8
|
+
v-for="column in columns"
|
|
9
|
+
:key="column.key"
|
|
10
|
+
class="data-table__header"
|
|
11
|
+
:class="{ 'data-table__header--sortable': column.sortable }"
|
|
12
|
+
@click="column.sortable ? handleSort(column.key) : undefined"
|
|
13
|
+
>
|
|
14
|
+
<div class="data-table__header-content">
|
|
15
|
+
{{ column.label }}
|
|
16
|
+
<span v-if="column.sortable" class="data-table__sort-icon">
|
|
17
|
+
<span v-if="sortKey === column.key">
|
|
18
|
+
{{ sortOrder === 'asc' ? '↑' : '↓' }}
|
|
19
|
+
</span>
|
|
20
|
+
<span v-else class="data-table__sort-icon--inactive">↕</span>
|
|
21
|
+
</span>
|
|
22
|
+
</div>
|
|
23
|
+
</th>
|
|
24
|
+
</tr>
|
|
25
|
+
</thead>
|
|
26
|
+
<tbody>
|
|
27
|
+
<tr
|
|
28
|
+
v-for="(row, index) in sortedData"
|
|
29
|
+
:key="index"
|
|
30
|
+
class="data-table__row"
|
|
31
|
+
>
|
|
32
|
+
<td
|
|
33
|
+
v-for="column in columns"
|
|
34
|
+
:key="column.key"
|
|
35
|
+
class="data-table__cell"
|
|
36
|
+
>
|
|
37
|
+
<slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]">
|
|
38
|
+
{{ row[column.key] }}
|
|
39
|
+
</slot>
|
|
40
|
+
</td>
|
|
41
|
+
</tr>
|
|
42
|
+
<tr v-if="sortedData.length === 0">
|
|
43
|
+
<td :colspan="columns.length" class="data-table__cell data-table__cell--empty">
|
|
44
|
+
{{ emptyText }}
|
|
45
|
+
</td>
|
|
46
|
+
</tr>
|
|
47
|
+
</tbody>
|
|
48
|
+
</table>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
</template>
|
|
52
|
+
|
|
53
|
+
<script setup lang="ts">
|
|
54
|
+
import { ref, computed } from 'vue'
|
|
55
|
+
|
|
56
|
+
export interface DataTableColumn {
|
|
57
|
+
key: string
|
|
58
|
+
label: string
|
|
59
|
+
sortable?: boolean
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface DataTableProps {
|
|
63
|
+
columns: DataTableColumn[]
|
|
64
|
+
data: Record<string, any>[]
|
|
65
|
+
hoverable?: boolean
|
|
66
|
+
emptyText?: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const props = withDefaults(defineProps<DataTableProps>(), {
|
|
70
|
+
hoverable: true,
|
|
71
|
+
emptyText: 'No data available'
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const sortKey = ref<string | null>(null)
|
|
75
|
+
const sortOrder = ref<'asc' | 'desc'>('asc')
|
|
76
|
+
|
|
77
|
+
const handleSort = (key: string) => {
|
|
78
|
+
if (sortKey.value === key) {
|
|
79
|
+
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
|
|
80
|
+
} else {
|
|
81
|
+
sortKey.value = key
|
|
82
|
+
sortOrder.value = 'asc'
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const sortedData = computed(() => {
|
|
87
|
+
if (!sortKey.value) {
|
|
88
|
+
return props.data
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return [...props.data].sort((a, b) => {
|
|
92
|
+
const aVal = a[sortKey.value!]
|
|
93
|
+
const bVal = b[sortKey.value!]
|
|
94
|
+
|
|
95
|
+
if (aVal === bVal) return 0
|
|
96
|
+
|
|
97
|
+
let comparison = 0
|
|
98
|
+
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
|
99
|
+
comparison = aVal - bVal
|
|
100
|
+
} else {
|
|
101
|
+
comparison = String(aVal).localeCompare(String(bVal))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return sortOrder.value === 'asc' ? comparison : -comparison
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
</script>
|
|
108
|
+
|
|
109
|
+
<style scoped>
|
|
110
|
+
.data-table {
|
|
111
|
+
width: 100%;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.data-table__wrapper {
|
|
115
|
+
overflow-x: auto;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.data-table__table {
|
|
119
|
+
min-width: 100%;
|
|
120
|
+
border-collapse: collapse;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.data-table__head {
|
|
124
|
+
background-color: #f9fafb;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.data-table__header {
|
|
128
|
+
padding: 0.75rem 1.5rem;
|
|
129
|
+
text-align: left;
|
|
130
|
+
font-size: 0.75rem;
|
|
131
|
+
font-weight: 500;
|
|
132
|
+
color: #6b7280;
|
|
133
|
+
text-transform: uppercase;
|
|
134
|
+
letter-spacing: 0.05em;
|
|
135
|
+
border-bottom: 1px solid #e5e7eb;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.data-table__header--sortable {
|
|
139
|
+
cursor: pointer;
|
|
140
|
+
user-select: none;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.data-table__header--sortable:hover {
|
|
144
|
+
background-color: #f3f4f6;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.data-table__header-content {
|
|
148
|
+
display: flex;
|
|
149
|
+
align-items: center;
|
|
150
|
+
gap: 0.5rem;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.data-table__sort-icon {
|
|
154
|
+
font-size: 0.75rem;
|
|
155
|
+
color: #6b7280;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.data-table__sort-icon--inactive {
|
|
159
|
+
color: #d1d5db;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.data-table__row {
|
|
163
|
+
border-bottom: 1px solid #e5e7eb;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.data-table__cell {
|
|
167
|
+
padding: 1rem 1.5rem;
|
|
168
|
+
font-size: 0.875rem;
|
|
169
|
+
color: #6b7280;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.data-table__cell--empty {
|
|
173
|
+
text-align: center;
|
|
174
|
+
color: #9ca3af;
|
|
175
|
+
font-style: italic;
|
|
176
|
+
}
|
|
177
|
+
</style>
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/vue3'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import DatePicker from './DatePicker.vue'
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Components/DatePicker',
|
|
7
|
+
component: DatePicker,
|
|
8
|
+
tags: ['autodocs']
|
|
9
|
+
} satisfies Meta<typeof DatePicker>
|
|
10
|
+
|
|
11
|
+
export default meta
|
|
12
|
+
type Story = StoryObj<typeof meta>
|
|
13
|
+
|
|
14
|
+
export const Default: Story = {
|
|
15
|
+
render: (args: any) => ({
|
|
16
|
+
components: { DatePicker },
|
|
17
|
+
setup() {
|
|
18
|
+
const date = ref<string>('')
|
|
19
|
+
return { args, date }
|
|
20
|
+
},
|
|
21
|
+
template: `
|
|
22
|
+
<div>
|
|
23
|
+
<DatePicker v-bind="args" v-model="date" />
|
|
24
|
+
<div style="margin-top: 1rem; padding: 1rem; background: #f3f4f6; border-radius: 0.5rem;">
|
|
25
|
+
Selected: {{ date || 'None' }}
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
`
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const WithValue: Story = {
|
|
33
|
+
render: (args: any) => ({
|
|
34
|
+
components: { DatePicker },
|
|
35
|
+
setup() {
|
|
36
|
+
const date = ref<string>('2026-03-15')
|
|
37
|
+
return { args, date }
|
|
38
|
+
},
|
|
39
|
+
template: `
|
|
40
|
+
<div>
|
|
41
|
+
<DatePicker v-bind="args" v-model="date" />
|
|
42
|
+
<div style="margin-top: 1rem; padding: 1rem; background: #f3f4f6; border-radius: 0.5rem;">
|
|
43
|
+
Selected: {{ date }}
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
`
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const DateRange: Story = {
|
|
51
|
+
render: (args: any) => ({
|
|
52
|
+
components: { DatePicker },
|
|
53
|
+
setup() {
|
|
54
|
+
const dateRange = ref<{ start: string; end: string }>({ start: '', end: '' })
|
|
55
|
+
return { args, dateRange }
|
|
56
|
+
},
|
|
57
|
+
template: `
|
|
58
|
+
<div>
|
|
59
|
+
<DatePicker v-bind="args" v-model="dateRange" mode="range" placeholder="Select date range" />
|
|
60
|
+
<div style="margin-top: 1rem; padding: 1rem; background: #f3f4f6; border-radius: 0.5rem;">
|
|
61
|
+
Start: {{ dateRange.start || 'Not set' }}<br />
|
|
62
|
+
End: {{ dateRange.end || 'Not set' }}
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
`
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const WithMinMaxDates: Story = {
|
|
70
|
+
render: (args: any) => ({
|
|
71
|
+
components: { DatePicker },
|
|
72
|
+
setup() {
|
|
73
|
+
const date = ref<string>('')
|
|
74
|
+
const today = new Date()
|
|
75
|
+
const minDate = today.toISOString().split('T')[0]
|
|
76
|
+
const maxDate = new Date(today.getFullYear(), today.getMonth() + 3, today.getDate()).toISOString().split('T')[0]
|
|
77
|
+
|
|
78
|
+
return { args, date, minDate, maxDate }
|
|
79
|
+
},
|
|
80
|
+
template: `
|
|
81
|
+
<div>
|
|
82
|
+
<DatePicker
|
|
83
|
+
v-bind="args"
|
|
84
|
+
v-model="date"
|
|
85
|
+
:min-date="minDate"
|
|
86
|
+
:max-date="maxDate"
|
|
87
|
+
placeholder="Future dates only"
|
|
88
|
+
/>
|
|
89
|
+
<div style="margin-top: 1rem; padding: 1rem; background: #f3f4f6; border-radius: 0.5rem;">
|
|
90
|
+
Selected: {{ date || 'None' }}<br />
|
|
91
|
+
<small style="color: #6b7280;">Min: {{ minDate }} | Max: {{ maxDate }}</small>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
`
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const FormExample: Story = {
|
|
99
|
+
render: (args: any) => ({
|
|
100
|
+
components: { DatePicker },
|
|
101
|
+
setup() {
|
|
102
|
+
const passportNumber = ref('')
|
|
103
|
+
const expiryDate = ref<string>('')
|
|
104
|
+
|
|
105
|
+
return { args, passportNumber, expiryDate }
|
|
106
|
+
},
|
|
107
|
+
template: `
|
|
108
|
+
<div style="max-width: 400px;">
|
|
109
|
+
<div style="margin-bottom: 1rem;">
|
|
110
|
+
<label style="display: block; margin-bottom: 0.5rem; font-weight: 600;">Passport Number</label>
|
|
111
|
+
<input
|
|
112
|
+
v-model="passportNumber"
|
|
113
|
+
type="text"
|
|
114
|
+
placeholder="e.g., X12345678"
|
|
115
|
+
style="width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.375rem;"
|
|
116
|
+
/>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<div style="margin-bottom: 1rem;">
|
|
120
|
+
<label style="display: block; margin-bottom: 0.5rem; font-weight: 600;">Expiry Date</label>
|
|
121
|
+
<DatePicker
|
|
122
|
+
v-bind="args"
|
|
123
|
+
v-model="expiryDate"
|
|
124
|
+
placeholder="Select expiry date"
|
|
125
|
+
/>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<div style="padding: 1rem; background: #f3f4f6; border-radius: 0.5rem;">
|
|
129
|
+
<strong>Form Data:</strong><br />
|
|
130
|
+
Passport: {{ passportNumber || 'Not entered' }}<br />
|
|
131
|
+
Expiry: {{ expiryDate || 'Not selected' }}
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
`
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const LongFormat: Story = {
|
|
139
|
+
render: (args: any) => ({
|
|
140
|
+
components: { DatePicker },
|
|
141
|
+
setup() {
|
|
142
|
+
const date = ref<string>('2026-06-15')
|
|
143
|
+
return { args, date }
|
|
144
|
+
},
|
|
145
|
+
template: `
|
|
146
|
+
<div>
|
|
147
|
+
<DatePicker v-bind="args" v-model="date" format="long" />
|
|
148
|
+
<div style="margin-top: 1rem; padding: 1rem; background: #f3f4f6; border-radius: 0.5rem;">
|
|
149
|
+
Selected: {{ date }}
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
`
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export const Disabled: Story = {
|
|
157
|
+
render: (args: any) => ({
|
|
158
|
+
components: { DatePicker },
|
|
159
|
+
setup() {
|
|
160
|
+
const date = ref<string>('2026-02-07')
|
|
161
|
+
return { args, date }
|
|
162
|
+
},
|
|
163
|
+
template: `
|
|
164
|
+
<div>
|
|
165
|
+
<DatePicker v-bind="args" v-model="date" disabled />
|
|
166
|
+
<div style="margin-top: 1rem; padding: 1rem; background: #f3f4f6; border-radius: 0.5rem;">
|
|
167
|
+
Selected: {{ date }}
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
`
|
|
171
|
+
})
|
|
172
|
+
}
|