@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 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`).
@@ -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,3 @@
1
+ export function cn(...classes: Array<string | false | null | undefined>) {
2
+ return classes.filter(Boolean).join(' ')
3
+ }
@@ -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>