@lyda/kilo-ui 0.1.0
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/LICENSE +21 -0
- package/README.md +167 -0
- package/bin/kilo-ui.mjs +175 -0
- package/package.json +52 -0
- package/registry/composables/useSortedFilteredList.ts +52 -0
- package/registry/index.json +75 -0
- package/registry/lib/utils.ts +3 -0
- package/registry/styles/teamwork-ui.css +33 -0
- package/registry/types/teamwork-ui.ts +29 -0
- package/registry/ui/activity-log/ActivityLog.vue +42 -0
- package/registry/ui/app-navbar/AppNavbar.vue +78 -0
- package/registry/ui/card-box/CardBox.vue +23 -0
- package/registry/ui/data-table/DataTable.vue +132 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# kilo-ui
|
|
2
|
+
|
|
3
|
+
shadcn-style Vue 3 registry for team CRUD apps.
|
|
4
|
+
|
|
5
|
+
This package does **not** work like a normal UI runtime dependency. It works like shadcn: the CLI copies component source code into the project that needs it. After that, the project owns the code and can edit it.
|
|
6
|
+
|
|
7
|
+
## Use In A Project
|
|
8
|
+
|
|
9
|
+
From any Vue project:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install -D "C:\UI Library"
|
|
13
|
+
npx kilo-ui init
|
|
14
|
+
npx kilo-ui add data-table app-navbar activity-log
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
After you **publish** this project to npm, install the **exact** `name` from its `package.json` (for example `@ui-library/kilo-ui`).
|
|
18
|
+
**Do not** run `npm install -D kilo-ui` expecting this Vue CLI — the public npm name `kilo-ui` is already used by a different project.
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install -D @ui-library/kilo-ui
|
|
22
|
+
npx kilo-ui init
|
|
23
|
+
npx kilo-ui add data-table
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
**Note:** The public npm name `kilo-ui` may point to a different project (not this Vue CLI). If `npm install -D kilo-ui` pulls SvelteKit or other wrong peers, use a scoped name or install from `file:` / Git. See [Installation](docs/docs/installation.md) in the docs site.
|
|
27
|
+
|
|
28
|
+
## What `init` Creates
|
|
29
|
+
|
|
30
|
+
`npx kilo-ui init` creates:
|
|
31
|
+
|
|
32
|
+
- `components.json` - config and aliases
|
|
33
|
+
- `src/styles/teamwork-ui.css` - Tailwind v4 import + Kilo UI theme tokens
|
|
34
|
+
- `src/lib/utils.ts` - shared helper utilities
|
|
35
|
+
|
|
36
|
+
Add the stylesheet once in your app entry, usually `src/main.ts`:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import './styles/teamwork-ui.css'
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Your app should also have the normal Vue `@` alias pointing to `src`. Most Vite Vue projects already do. If not, add this to `vite.config.ts`:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { fileURLToPath, URL } from 'node:url'
|
|
46
|
+
|
|
47
|
+
resolve: {
|
|
48
|
+
alias: {
|
|
49
|
+
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## What `add` Creates
|
|
55
|
+
|
|
56
|
+
`npx kilo-ui add data-table` copies source into your app:
|
|
57
|
+
|
|
58
|
+
```txt
|
|
59
|
+
src/components/ui/data-table/DataTable.vue
|
|
60
|
+
src/composables/useSortedFilteredList.ts
|
|
61
|
+
src/types/teamwork-ui.ts
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Then use it from your own source files:
|
|
65
|
+
|
|
66
|
+
```vue
|
|
67
|
+
<script setup lang="ts">
|
|
68
|
+
import DataTable from '@/components/ui/data-table/DataTable.vue'
|
|
69
|
+
import type { DataTableColumn } from '@/types/teamwork-ui'
|
|
70
|
+
|
|
71
|
+
type User = { id: string; name: string; email: string }
|
|
72
|
+
|
|
73
|
+
const columns: DataTableColumn<User>[] = [
|
|
74
|
+
{ key: 'name', label: 'Name', sortable: true },
|
|
75
|
+
{ key: 'email', label: 'Email', sortable: true },
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
const users: User[] = [
|
|
79
|
+
{ id: '1', name: 'Ashiba', email: 'ashiba@example.com' },
|
|
80
|
+
]
|
|
81
|
+
</script>
|
|
82
|
+
|
|
83
|
+
<template>
|
|
84
|
+
<DataTable :columns="columns" :rows="users" />
|
|
85
|
+
</template>
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Available Components
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
npx kilo-ui list
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Current registry items:
|
|
95
|
+
|
|
96
|
+
- `data-table` - searchable, sortable CRUD table
|
|
97
|
+
- `app-navbar` - responsive web/mobile navbar
|
|
98
|
+
- `activity-log` - user/activity log
|
|
99
|
+
|
|
100
|
+
## Customize
|
|
101
|
+
|
|
102
|
+
Because components are copied into the app, your team can edit them directly:
|
|
103
|
+
|
|
104
|
+
```txt
|
|
105
|
+
src/components/ui/data-table/DataTable.vue
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Theme tokens live in `src/styles/teamwork-ui.css`:
|
|
109
|
+
|
|
110
|
+
```css
|
|
111
|
+
@theme {
|
|
112
|
+
--color-twui-accent: #16a34a;
|
|
113
|
+
--radius-twui: 0.5rem;
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Documentation Site
|
|
118
|
+
|
|
119
|
+
A shadcn-style docs site lives in `docs/` and is built with VitePress.
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
npm install
|
|
123
|
+
npm run docs:dev # http://localhost:5174
|
|
124
|
+
npm run docs:build # static build in docs/.vitepress/dist
|
|
125
|
+
npm run docs:preview # preview the production build
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
It includes:
|
|
129
|
+
|
|
130
|
+
- A landing page
|
|
131
|
+
- Get Started (Introduction, Installation, CLI, Theming)
|
|
132
|
+
- Components index + per-component pages with live preview, source tab, and props tables
|
|
133
|
+
- Live previews import directly from `registry/` so docs and what the CLI ships never drift
|
|
134
|
+
|
|
135
|
+
## Repository Layout
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
bin/ # The kilo-ui CLI (Node, zero-dep)
|
|
139
|
+
registry/ # Source-of-truth for components copied by the CLI
|
|
140
|
+
docs/ # VitePress docs site (mirrors ui.shadcn.com style)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Editing a component? Update the file in `registry/`. The CLI and docs both pick it up automatically.
|
|
144
|
+
|
|
145
|
+
## Publish to npm (so others can install your CLI)
|
|
146
|
+
|
|
147
|
+
1. Create an [npm](https://www.npmjs.com/) account and log in: `npm login`
|
|
148
|
+
2. Use a scoped package name you control (recommended). The unscoped npm name `kilo-ui` is already taken by a different project.
|
|
149
|
+
3. Bump version when you ship changes: `npm version patch` (or `minor` / `major`)
|
|
150
|
+
4. From this repo root:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
npm publish --access public
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
For a **scoped** public package: `npm publish --access public`
|
|
157
|
+
For **private** paid npm org: `npm publish --access restricted`
|
|
158
|
+
|
|
159
|
+
5. Consumers install with:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
npm install -D @ui-library/kilo-ui
|
|
163
|
+
npx kilo-ui init
|
|
164
|
+
npx kilo-ui add data-table
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
**Note:** This package only ships `bin/`, `registry/`, and `README.md` (`files` in `package.json`). Docs and devDependencies are not published. Host the docs site separately (`npm run docs:build` → upload `docs/.vitepress/dist`).
|
package/bin/kilo-ui.mjs
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { dirname, join, relative, resolve } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
7
|
+
const packageRoot = resolve(__dirname, '..')
|
|
8
|
+
const registryRoot = join(packageRoot, 'registry')
|
|
9
|
+
|
|
10
|
+
const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'))
|
|
11
|
+
const cliName = typeof pkg.name === 'string' ? pkg.name : 'kilo-ui'
|
|
12
|
+
|
|
13
|
+
const commands = new Set(['init', 'add', 'list', 'help'])
|
|
14
|
+
const [, , maybeCommand, ...args] = process.argv
|
|
15
|
+
const command = commands.has(maybeCommand) ? maybeCommand : 'help'
|
|
16
|
+
|
|
17
|
+
const configFile = 'components.json'
|
|
18
|
+
|
|
19
|
+
function log(message = '') {
|
|
20
|
+
console.log(message)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function fail(message) {
|
|
24
|
+
console.error(`\nError: ${message}`)
|
|
25
|
+
process.exit(1)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readJson(path) {
|
|
29
|
+
return JSON.parse(readFileSync(path, 'utf8'))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function writeJson(path, value) {
|
|
33
|
+
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ensureDir(path) {
|
|
37
|
+
mkdirSync(path, { recursive: true })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writeFile(path, content, force = false) {
|
|
41
|
+
if (existsSync(path) && !force) {
|
|
42
|
+
log(`skip ${relative(process.cwd(), path)} (already exists)`)
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
ensureDir(dirname(path))
|
|
46
|
+
writeFileSync(path, content, 'utf8')
|
|
47
|
+
log(`${existsSync(path) && force ? 'write' : 'add'} ${relative(process.cwd(), path)}`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function registry() {
|
|
51
|
+
return readJson(join(registryRoot, 'index.json'))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function projectConfig() {
|
|
55
|
+
const path = join(process.cwd(), configFile)
|
|
56
|
+
if (!existsSync(path)) {
|
|
57
|
+
fail(`Missing ${configFile}. Run "npx ${cliName} init" first.`)
|
|
58
|
+
}
|
|
59
|
+
return readJson(path)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function aliasPath(config, key) {
|
|
63
|
+
const value = config.aliases?.[key]
|
|
64
|
+
if (!value) fail(`Missing aliases.${key} in ${configFile}`)
|
|
65
|
+
return value.replace(/^@\//, config.srcDir ? `${config.srcDir}/` : 'src/')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function resolveTarget(config, alias, fileName) {
|
|
69
|
+
return join(process.cwd(), aliasPath(config, alias), fileName)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function componentFilePath(config, componentName, fileName) {
|
|
73
|
+
return join(process.cwd(), aliasPath(config, 'components'), 'ui', componentName, fileName)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function copyRegistryFile(sourceRelative, targetPath, config, force) {
|
|
77
|
+
const sourcePath = join(registryRoot, sourceRelative)
|
|
78
|
+
if (!existsSync(sourcePath)) fail(`Registry file not found: ${sourceRelative}`)
|
|
79
|
+
const content = readFileSync(sourcePath, 'utf8')
|
|
80
|
+
.replaceAll('__COMPONENTS_ALIAS__', config.aliases.components)
|
|
81
|
+
.replaceAll('__COMPOSABLES_ALIAS__', config.aliases.composables)
|
|
82
|
+
.replaceAll('__LIB_ALIAS__', config.aliases.lib)
|
|
83
|
+
.replaceAll('__TYPES_ALIAS__', config.aliases.types)
|
|
84
|
+
writeFile(targetPath, content, force)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function init(args) {
|
|
88
|
+
const force = args.includes('--force')
|
|
89
|
+
const configPath = join(process.cwd(), configFile)
|
|
90
|
+
const defaultConfig = {
|
|
91
|
+
$schema: 'https://kilo-ui.local/schema.json',
|
|
92
|
+
style: 'default',
|
|
93
|
+
srcDir: 'src',
|
|
94
|
+
tailwind: {
|
|
95
|
+
css: 'src/styles/teamwork-ui.css',
|
|
96
|
+
},
|
|
97
|
+
aliases: {
|
|
98
|
+
components: '@/components',
|
|
99
|
+
composables: '@/composables',
|
|
100
|
+
lib: '@/lib',
|
|
101
|
+
types: '@/types',
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
const config = existsSync(configPath) && !force ? readJson(configPath) : defaultConfig
|
|
105
|
+
|
|
106
|
+
if (existsSync(configPath) && !force) {
|
|
107
|
+
log(`skip ${configFile} (already exists)`)
|
|
108
|
+
} else {
|
|
109
|
+
writeJson(configPath, config)
|
|
110
|
+
log(`write ${configFile}`)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
copyRegistryFile('styles/teamwork-ui.css', join(process.cwd(), config.tailwind.css), config, force)
|
|
114
|
+
copyRegistryFile('lib/utils.ts', resolveTarget(config, 'lib', 'utils.ts'), config, force)
|
|
115
|
+
|
|
116
|
+
log('\nDone. Add this once in your app entry or main stylesheet:')
|
|
117
|
+
log(` import './styles/${config.tailwind.css.replace(/^.*\//, '')}'`)
|
|
118
|
+
log('\nThen add components:')
|
|
119
|
+
log(` npx ${cliName} add data-table app-navbar activity-log`)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function add(args) {
|
|
123
|
+
const force = args.includes('--force')
|
|
124
|
+
const names = args.filter((arg) => !arg.startsWith('--'))
|
|
125
|
+
if (names.length === 0) fail(`Tell me what to add, for example: npx ${cliName} add data-table`)
|
|
126
|
+
|
|
127
|
+
const config = projectConfig()
|
|
128
|
+
const items = registry().items
|
|
129
|
+
|
|
130
|
+
for (const name of names) {
|
|
131
|
+
const item = items.find((candidate) => candidate.name === name)
|
|
132
|
+
if (!item) {
|
|
133
|
+
fail(`Unknown component "${name}". Run "npx ${cliName} list" to see available components.`)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
log(`\n${item.name}`)
|
|
137
|
+
for (const file of item.files) {
|
|
138
|
+
let targetPath
|
|
139
|
+
if (file.target === 'components') {
|
|
140
|
+
targetPath = componentFilePath(config, item.name, file.name)
|
|
141
|
+
} else {
|
|
142
|
+
targetPath = resolveTarget(config, file.target, file.name)
|
|
143
|
+
}
|
|
144
|
+
copyRegistryFile(file.path, targetPath, config, force)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
log('\nDone.')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function list() {
|
|
152
|
+
log('Available components:\n')
|
|
153
|
+
for (const item of registry().items) {
|
|
154
|
+
log(` ${item.name.padEnd(14)} ${item.description}`)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function help() {
|
|
159
|
+
log(`${cliName}
|
|
160
|
+
|
|
161
|
+
Usage:
|
|
162
|
+
npx ${cliName} init [--force]
|
|
163
|
+
npx ${cliName} add <component...> [--force]
|
|
164
|
+
npx ${cliName} list
|
|
165
|
+
|
|
166
|
+
Examples:
|
|
167
|
+
npx ${cliName} init
|
|
168
|
+
npx ${cliName} add data-table app-navbar
|
|
169
|
+
`)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (command === 'init') init(args)
|
|
173
|
+
if (command === 'add') add(args)
|
|
174
|
+
if (command === 'list') list()
|
|
175
|
+
if (command === 'help') help()
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lyda/kilo-ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "shadcn-style Vue component registry for team CRUD apps",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/ui-library/kilo-ui.git"
|
|
9
|
+
},
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/ui-library/kilo-ui/issues"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/ui-library/kilo-ui#readme",
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"bin": {
|
|
18
|
+
"kilo-ui": "bin/kilo-ui.mjs"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"bin",
|
|
22
|
+
"registry",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"docs:dev": "vitepress dev docs",
|
|
27
|
+
"docs:build": "vitepress build docs",
|
|
28
|
+
"docs:preview": "vitepress preview docs",
|
|
29
|
+
"typecheck": "vue-tsc --noEmit -p tsconfig.json",
|
|
30
|
+
"check:cli": "node ./bin/kilo-ui.mjs list"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@tailwindcss/vite": "^4.2.4",
|
|
34
|
+
"@types/node": "^22.19.17",
|
|
35
|
+
"tailwindcss": "^4.2.4",
|
|
36
|
+
"typescript": "~5.7.2",
|
|
37
|
+
"vitepress": "^1.6.4",
|
|
38
|
+
"vue": "^3.5.13",
|
|
39
|
+
"vue-tsc": "^2.1.10"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"vue": "^3.4.0"
|
|
43
|
+
},
|
|
44
|
+
"keywords": [
|
|
45
|
+
"vue",
|
|
46
|
+
"vue3",
|
|
47
|
+
"components",
|
|
48
|
+
"crud",
|
|
49
|
+
"ui"
|
|
50
|
+
],
|
|
51
|
+
"license": "MIT"
|
|
52
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { computed, type ComputedRef, type Ref } from 'vue'
|
|
2
|
+
import type { SortDirection } from '__TYPES_ALIAS__/teamwork-ui'
|
|
3
|
+
|
|
4
|
+
export interface UseSortedFilteredListOptions<T extends Record<string, unknown>> {
|
|
5
|
+
rows: Ref<T[]> | ComputedRef<T[]>
|
|
6
|
+
search: Ref<string>
|
|
7
|
+
/** Row keys to include in text search (default: all keys) */
|
|
8
|
+
searchKeys?: (keyof T & string)[]
|
|
9
|
+
sortKey: Ref<string | null>
|
|
10
|
+
sortDir: Ref<SortDirection>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function defaultSearchKeys<T extends Record<string, unknown>>(rows: T[]): (keyof T & string)[] {
|
|
14
|
+
if (rows.length === 0) return []
|
|
15
|
+
return Object.keys(rows[0]!) as (keyof T & string)[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function cellText(value: unknown): string {
|
|
19
|
+
if (value == null) return ''
|
|
20
|
+
if (typeof value === 'object') return JSON.stringify(value)
|
|
21
|
+
return String(value)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useSortedFilteredList<T extends Record<string, unknown>>(
|
|
25
|
+
options: UseSortedFilteredListOptions<T>,
|
|
26
|
+
): ComputedRef<T[]> {
|
|
27
|
+
return computed(() => {
|
|
28
|
+
const keys =
|
|
29
|
+
options.searchKeys?.length ? options.searchKeys : defaultSearchKeys(options.rows.value)
|
|
30
|
+
|
|
31
|
+
let list = [...options.rows.value]
|
|
32
|
+
|
|
33
|
+
const q = options.search.value.trim().toLowerCase()
|
|
34
|
+
if (q) {
|
|
35
|
+
list = list.filter((row) =>
|
|
36
|
+
keys.some((k) => cellText(row[k]).toLowerCase().includes(q)),
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const sk = options.sortKey.value
|
|
41
|
+
if (sk) {
|
|
42
|
+
const dir = options.sortDir.value === 'asc' ? 1 : -1
|
|
43
|
+
list.sort((a, b) => {
|
|
44
|
+
const av = cellText(a[sk as keyof T])
|
|
45
|
+
const bv = cellText(b[sk as keyof T])
|
|
46
|
+
return av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' }) * dir
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return list
|
|
51
|
+
})
|
|
52
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://kilo-ui.local/registry.schema.json",
|
|
3
|
+
"name": "kilo-ui",
|
|
4
|
+
"homepage": "https://kilo-ui.local",
|
|
5
|
+
"items": [
|
|
6
|
+
{
|
|
7
|
+
"name": "data-table",
|
|
8
|
+
"type": "registry:ui",
|
|
9
|
+
"description": "Searchable, sortable table for CRUD list pages.",
|
|
10
|
+
"files": [
|
|
11
|
+
{
|
|
12
|
+
"path": "ui/data-table/DataTable.vue",
|
|
13
|
+
"target": "components",
|
|
14
|
+
"name": "DataTable.vue"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"path": "composables/useSortedFilteredList.ts",
|
|
18
|
+
"target": "composables",
|
|
19
|
+
"name": "useSortedFilteredList.ts"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"path": "types/teamwork-ui.ts",
|
|
23
|
+
"target": "types",
|
|
24
|
+
"name": "teamwork-ui.ts"
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"name": "app-navbar",
|
|
30
|
+
"type": "registry:ui",
|
|
31
|
+
"description": "Responsive navbar with mobile menu and brand slot.",
|
|
32
|
+
"files": [
|
|
33
|
+
{
|
|
34
|
+
"path": "ui/app-navbar/AppNavbar.vue",
|
|
35
|
+
"target": "components",
|
|
36
|
+
"name": "AppNavbar.vue"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"path": "types/teamwork-ui.ts",
|
|
40
|
+
"target": "types",
|
|
41
|
+
"name": "teamwork-ui.ts"
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"name": "activity-log",
|
|
47
|
+
"type": "registry:ui",
|
|
48
|
+
"description": "Simple activity/user log component for audit trails.",
|
|
49
|
+
"files": [
|
|
50
|
+
{
|
|
51
|
+
"path": "ui/activity-log/ActivityLog.vue",
|
|
52
|
+
"target": "components",
|
|
53
|
+
"name": "ActivityLog.vue"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"path": "types/teamwork-ui.ts",
|
|
57
|
+
"target": "types",
|
|
58
|
+
"name": "teamwork-ui.ts"
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"name": "card-box",
|
|
64
|
+
"type": "registry:ui",
|
|
65
|
+
"description": "Surface container with header, content, and footer slots.",
|
|
66
|
+
"files": [
|
|
67
|
+
{
|
|
68
|
+
"path": "ui/card-box/CardBox.vue",
|
|
69
|
+
"target": "components",
|
|
70
|
+
"name": "CardBox.vue"
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
@import 'tailwindcss';
|
|
2
|
+
|
|
3
|
+
/* Scan copied Teamwork UI files inside the consuming app. */
|
|
4
|
+
@source "../components/ui/**/*.{vue,ts}";
|
|
5
|
+
@source "../composables/**/*.{ts,vue}";
|
|
6
|
+
@source "../lib/**/*.{ts,vue}";
|
|
7
|
+
|
|
8
|
+
@theme {
|
|
9
|
+
--color-twui-accent: #2563eb;
|
|
10
|
+
--color-twui-accent-contrast: #ffffff;
|
|
11
|
+
--color-twui-danger: #dc2626;
|
|
12
|
+
--color-twui-surface: #ffffff;
|
|
13
|
+
--color-twui-surface-2: #f8fafc;
|
|
14
|
+
--color-twui-border: #e2e8f0;
|
|
15
|
+
--color-twui-muted: #64748b;
|
|
16
|
+
--color-twui-text: #0f172a;
|
|
17
|
+
--radius-twui: 0.625rem;
|
|
18
|
+
--radius-twui-sm: 0.375rem;
|
|
19
|
+
--shadow-twui: 0 1px 2px rgb(15 23 42 / 0.08);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@media (prefers-color-scheme: dark) {
|
|
23
|
+
@theme {
|
|
24
|
+
--color-twui-surface: #0f172a;
|
|
25
|
+
--color-twui-surface-2: #111c2e;
|
|
26
|
+
--color-twui-border: #1f2a40;
|
|
27
|
+
--color-twui-muted: #94a3b8;
|
|
28
|
+
--color-twui-text: #f8fafc;
|
|
29
|
+
--color-twui-accent: #60a5fa;
|
|
30
|
+
--color-twui-accent-contrast: #0b1220;
|
|
31
|
+
--shadow-twui: 0 1px 2px rgb(0 0 0 / 0.4);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type SortDirection = 'asc' | 'desc'
|
|
2
|
+
|
|
3
|
+
export interface DataTableColumn<T extends Record<string, unknown>> {
|
|
4
|
+
/** Key on row object */
|
|
5
|
+
key: keyof T & string
|
|
6
|
+
/** Column header label */
|
|
7
|
+
label: string
|
|
8
|
+
/** Enable click-to-sort on this column */
|
|
9
|
+
sortable?: boolean
|
|
10
|
+
/** Optional formatter for cell display */
|
|
11
|
+
format?: (value: unknown, row: T) => string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ActivityLogItem {
|
|
15
|
+
id: string
|
|
16
|
+
/** ISO date string or display-ready time */
|
|
17
|
+
at: string
|
|
18
|
+
/** Who performed the action */
|
|
19
|
+
actor: string
|
|
20
|
+
/** Short description */
|
|
21
|
+
message: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface NavbarLink {
|
|
25
|
+
label: string
|
|
26
|
+
href: string
|
|
27
|
+
/** Mark current route (you set this from the app) */
|
|
28
|
+
active?: boolean
|
|
29
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { ActivityLogItem } from '__TYPES_ALIAS__/teamwork-ui'
|
|
3
|
+
|
|
4
|
+
defineProps<{
|
|
5
|
+
items: ActivityLogItem[]
|
|
6
|
+
title?: string
|
|
7
|
+
}>()
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<section
|
|
12
|
+
aria-label="Activity log"
|
|
13
|
+
class="rounded-[var(--radius-twui)] border border-twui-border bg-twui-surface p-4 text-twui-text shadow-twui"
|
|
14
|
+
>
|
|
15
|
+
<header v-if="title" class="mb-4 text-base font-bold">
|
|
16
|
+
{{ title }}
|
|
17
|
+
</header>
|
|
18
|
+
|
|
19
|
+
<ol class="m-0 flex list-none flex-col gap-4 p-0">
|
|
20
|
+
<li
|
|
21
|
+
v-for="item in items"
|
|
22
|
+
:key="item.id"
|
|
23
|
+
class="grid grid-cols-1 items-start gap-3 sm:grid-cols-[8rem_1fr]"
|
|
24
|
+
>
|
|
25
|
+
<time
|
|
26
|
+
:datetime="item.at"
|
|
27
|
+
class="text-xs whitespace-nowrap text-twui-muted"
|
|
28
|
+
>
|
|
29
|
+
{{ item.at }}
|
|
30
|
+
</time>
|
|
31
|
+
<div class="flex flex-col gap-1">
|
|
32
|
+
<span class="text-sm font-semibold">{{ item.actor }}</span>
|
|
33
|
+
<span class="text-sm leading-snug">{{ item.message }}</span>
|
|
34
|
+
</div>
|
|
35
|
+
</li>
|
|
36
|
+
</ol>
|
|
37
|
+
|
|
38
|
+
<p v-if="items.length === 0" class="m-0 text-sm text-twui-muted">
|
|
39
|
+
No activity yet.
|
|
40
|
+
</p>
|
|
41
|
+
</section>
|
|
42
|
+
</template>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { onMounted, onUnmounted, ref } from 'vue'
|
|
3
|
+
import type { NavbarLink } from '__TYPES_ALIAS__/teamwork-ui'
|
|
4
|
+
|
|
5
|
+
defineProps<{
|
|
6
|
+
links: NavbarLink[]
|
|
7
|
+
/** Accessible label for the menu button */
|
|
8
|
+
menuLabel?: string
|
|
9
|
+
}>()
|
|
10
|
+
|
|
11
|
+
const open = ref(false)
|
|
12
|
+
|
|
13
|
+
function close() {
|
|
14
|
+
open.value = false
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function onKeydown(e: KeyboardEvent) {
|
|
18
|
+
if (e.key === 'Escape') close()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
onMounted(() => document.addEventListener('keydown', onKeydown))
|
|
22
|
+
onUnmounted(() => document.removeEventListener('keydown', onKeydown))
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<header
|
|
27
|
+
class="sticky top-0 z-40 border-b border-twui-border bg-twui-surface text-twui-text"
|
|
28
|
+
>
|
|
29
|
+
<div
|
|
30
|
+
class="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3"
|
|
31
|
+
>
|
|
32
|
+
<div class="text-base font-bold tracking-tight">
|
|
33
|
+
<slot name="brand">App</slot>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<button
|
|
37
|
+
type="button"
|
|
38
|
+
:aria-expanded="open"
|
|
39
|
+
:aria-label="menuLabel ?? 'Open menu'"
|
|
40
|
+
class="inline-flex h-10 w-10 items-center justify-center rounded-[var(--radius-twui-sm)] border border-twui-border bg-twui-surface-2 md:hidden"
|
|
41
|
+
@click="open = !open"
|
|
42
|
+
>
|
|
43
|
+
<span class="relative block h-0.5 w-5 rounded bg-current">
|
|
44
|
+
<span
|
|
45
|
+
class="absolute -top-1.5 left-0 block h-0.5 w-5 rounded bg-current"
|
|
46
|
+
/>
|
|
47
|
+
<span
|
|
48
|
+
class="absolute top-1.5 left-0 block h-0.5 w-5 rounded bg-current"
|
|
49
|
+
/>
|
|
50
|
+
</span>
|
|
51
|
+
</button>
|
|
52
|
+
|
|
53
|
+
<nav
|
|
54
|
+
aria-label="Main"
|
|
55
|
+
class="absolute top-full right-0 left-0 flex-col gap-1 border-b border-twui-border bg-twui-surface px-4 pt-3 pb-4 shadow-twui md:static md:flex md:flex-row md:items-center md:gap-2 md:border-0 md:bg-transparent md:p-0 md:shadow-none"
|
|
56
|
+
:class="open ? 'flex' : 'hidden md:flex'"
|
|
57
|
+
>
|
|
58
|
+
<a
|
|
59
|
+
v-for="link in links"
|
|
60
|
+
:key="link.href"
|
|
61
|
+
:href="link.href"
|
|
62
|
+
class="rounded-[var(--radius-twui-sm)] px-3 py-2 text-sm font-medium text-twui-text no-underline hover:bg-twui-surface-2"
|
|
63
|
+
:class="
|
|
64
|
+
link.active
|
|
65
|
+
? 'bg-twui-accent/15 text-twui-accent hover:bg-twui-accent/20'
|
|
66
|
+
: ''
|
|
67
|
+
"
|
|
68
|
+
@click="close"
|
|
69
|
+
>
|
|
70
|
+
{{ link.label }}
|
|
71
|
+
</a>
|
|
72
|
+
<div class="mt-2 md:mt-0 md:ml-2" @click="close">
|
|
73
|
+
<slot />
|
|
74
|
+
</div>
|
|
75
|
+
</nav>
|
|
76
|
+
</div>
|
|
77
|
+
</header>
|
|
78
|
+
</template>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
withDefaults(
|
|
3
|
+
defineProps<{
|
|
4
|
+
title?: string
|
|
5
|
+
description?: string
|
|
6
|
+
image?: string
|
|
7
|
+
flush?: boolean
|
|
8
|
+
}>(),
|
|
9
|
+
{ flush: false },
|
|
10
|
+
)
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<template>
|
|
14
|
+
<div
|
|
15
|
+
class="border rounded-2xl border-twui-border bg-twui-surface text-twui-text shadow-twui"
|
|
16
|
+
>
|
|
17
|
+
<div :class="flush ? '' : 'px-2 py-1.5'">
|
|
18
|
+
<slot name="content">
|
|
19
|
+
<slot />
|
|
20
|
+
</slot>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
</template>
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
<script setup lang="ts" generic="T extends Record<string, unknown>">
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
import type { DataTableColumn, SortDirection } from '__TYPES_ALIAS__/teamwork-ui'
|
|
4
|
+
import { useSortedFilteredList } from '__COMPOSABLES_ALIAS__/useSortedFilteredList'
|
|
5
|
+
|
|
6
|
+
const props = withDefaults(
|
|
7
|
+
defineProps<{
|
|
8
|
+
columns: DataTableColumn<T>[]
|
|
9
|
+
rows: T[]
|
|
10
|
+
/** Placeholder for the search field */
|
|
11
|
+
searchPlaceholder?: string
|
|
12
|
+
/** Show built-in search input */
|
|
13
|
+
showSearch?: boolean
|
|
14
|
+
/** Initial sort column key */
|
|
15
|
+
initialSortKey?: string | null
|
|
16
|
+
initialSortDir?: SortDirection
|
|
17
|
+
}>(),
|
|
18
|
+
{
|
|
19
|
+
searchPlaceholder: 'Search...',
|
|
20
|
+
showSearch: true,
|
|
21
|
+
initialSortKey: null,
|
|
22
|
+
initialSortDir: 'asc',
|
|
23
|
+
},
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
const emit = defineEmits<{
|
|
27
|
+
rowClick: [row: T]
|
|
28
|
+
}>()
|
|
29
|
+
|
|
30
|
+
const search = ref('')
|
|
31
|
+
const sortKey = ref<string | null>(props.initialSortKey ?? null)
|
|
32
|
+
const sortDir = ref<SortDirection>(props.initialSortDir)
|
|
33
|
+
|
|
34
|
+
const rowsRef = computed(() => props.rows)
|
|
35
|
+
|
|
36
|
+
const displayRows = useSortedFilteredList<T>({
|
|
37
|
+
rows: rowsRef,
|
|
38
|
+
search,
|
|
39
|
+
sortKey,
|
|
40
|
+
sortDir,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
function toggleSort(col: DataTableColumn<T>) {
|
|
44
|
+
if (!col.sortable) return
|
|
45
|
+
if (sortKey.value !== col.key) {
|
|
46
|
+
sortKey.value = col.key
|
|
47
|
+
sortDir.value = 'asc'
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function displayCell(row: T, col: DataTableColumn<T>): string {
|
|
54
|
+
const raw = row[col.key]
|
|
55
|
+
return col.format ? col.format(raw, row) : raw == null ? '' : String(raw)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sortIndicator(col: DataTableColumn<T>): string {
|
|
59
|
+
if (!col.sortable || sortKey.value !== col.key) return ''
|
|
60
|
+
return sortDir.value === 'asc' ? ' ▲' : ' ▼'
|
|
61
|
+
}
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<template>
|
|
65
|
+
<div
|
|
66
|
+
class="overflow-hidden rounded-[var(--radius-twui)] border border-twui-border bg-twui-surface text-twui-text shadow-twui"
|
|
67
|
+
>
|
|
68
|
+
<div
|
|
69
|
+
v-if="showSearch"
|
|
70
|
+
class="border-b border-twui-border bg-twui-surface-2 px-4 py-3"
|
|
71
|
+
>
|
|
72
|
+
<input
|
|
73
|
+
v-model="search"
|
|
74
|
+
type="search"
|
|
75
|
+
:placeholder="searchPlaceholder"
|
|
76
|
+
autocomplete="off"
|
|
77
|
+
class="w-full max-w-xs rounded-[var(--radius-twui-sm)] border border-twui-border bg-twui-surface px-3 py-2 text-sm text-twui-text outline-none placeholder:text-twui-muted focus:border-twui-accent focus:ring-2 focus:ring-twui-accent/30"
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div class="overflow-x-auto">
|
|
82
|
+
<table class="w-full border-collapse text-sm">
|
|
83
|
+
<thead class="bg-twui-surface-2 text-twui-text">
|
|
84
|
+
<tr>
|
|
85
|
+
<th
|
|
86
|
+
v-for="col in columns"
|
|
87
|
+
:key="col.key"
|
|
88
|
+
scope="col"
|
|
89
|
+
class="border-b border-twui-border px-4 py-3 text-left font-semibold whitespace-nowrap"
|
|
90
|
+
:class="col.sortable ? 'cursor-pointer select-none hover:text-twui-accent' : ''"
|
|
91
|
+
@click="toggleSort(col)"
|
|
92
|
+
>
|
|
93
|
+
{{ col.label }}{{ sortIndicator(col) }}
|
|
94
|
+
</th>
|
|
95
|
+
<th
|
|
96
|
+
v-if="$slots.actions"
|
|
97
|
+
scope="col"
|
|
98
|
+
class="w-px border-b border-twui-border px-4 py-3 text-right whitespace-nowrap"
|
|
99
|
+
/>
|
|
100
|
+
</tr>
|
|
101
|
+
</thead>
|
|
102
|
+
<tbody>
|
|
103
|
+
<tr
|
|
104
|
+
v-for="(row, idx) in displayRows"
|
|
105
|
+
:key="idx"
|
|
106
|
+
class="cursor-pointer hover:bg-twui-accent/10"
|
|
107
|
+
@click="emit('rowClick', row)"
|
|
108
|
+
>
|
|
109
|
+
<td
|
|
110
|
+
v-for="col in columns"
|
|
111
|
+
:key="col.key"
|
|
112
|
+
class="border-b border-twui-border px-4 py-3 align-middle last:border-b-0"
|
|
113
|
+
>
|
|
114
|
+
{{ displayCell(row, col) }}
|
|
115
|
+
</td>
|
|
116
|
+
<td
|
|
117
|
+
v-if="$slots.actions"
|
|
118
|
+
class="w-px border-b border-twui-border px-4 py-3 text-right whitespace-nowrap last:border-b-0"
|
|
119
|
+
@click.stop
|
|
120
|
+
>
|
|
121
|
+
<slot name="actions" :row="row" />
|
|
122
|
+
</td>
|
|
123
|
+
</tr>
|
|
124
|
+
</tbody>
|
|
125
|
+
</table>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<p v-if="displayRows.length === 0" class="m-0 px-4 py-4 text-sm text-twui-muted">
|
|
129
|
+
No rows match.
|
|
130
|
+
</p>
|
|
131
|
+
</div>
|
|
132
|
+
</template>
|