├── mise.toml
├── .env.example
├── src
├── styles
│ └── global.css
├── components
│ ├── Sign
│ │ ├── translations
│ │ │ └── en.json
│ │ ├── Sign.vue
│ │ └── SignCard.vue
│ ├── Header
│ │ └── wa-header.ts
│ ├── col-comb
│ │ ├── col-comb.styles.css.ts
│ │ └── col-comb.ts
│ ├── AskInput
│ │ └── AskInput.vue
│ └── IssueCard
│ │ └── IssueCard.vue
├── lib
│ ├── auth-client.ts
│ └── auth-server.ts
├── pages
│ ├── api
│ │ └── auth
│ │ │ └── [...all].ts
│ └── index.astro
└── layouts
│ └── Base.astro
├── .meta
└── rules
│ ├── commit.md
│ ├── README.md
│ └── basic_rules.md
├── public
├── icons
│ ├── new-line.svg
│ ├── time.svg
│ ├── send.svg
│ ├── wallet.svg
│ ├── chat.svg
│ ├── key.svg
│ └── github.svg
└── favicon.svg
├── tsconfig.json
├── db
└── config.ts
├── .gitignore
├── uno.config.ts
├── astro.config.ts
├── package.json
└── README.md
/mise.toml:
--------------------------------------------------------------------------------
1 | [tools]
2 | node = "22.11"
3 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | GITHUB_CLIENT_ID=
2 | GITHUB_CLIENT_SECRET=
3 | ASTRO_DB_REMOTE_URL=file://.meta/data/content.db
4 |
--------------------------------------------------------------------------------
/src/styles/global.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --wa-form-control-border-radius: var(--wa-border-radius-s);
3 | --wa-font-family-body: var(--default-font-family);
4 | }
5 |
--------------------------------------------------------------------------------
/src/components/Sign/translations/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "signIn": "Sign in",
3 | "social": [
4 | { "github": "Sign in with Github", "siwe": "Sign in with browser wallet" }
5 | ],
6 | "passkey": "Sign in with passkey"
7 | }
8 |
--------------------------------------------------------------------------------
/.meta/rules/commit.md:
--------------------------------------------------------------------------------
1 | Analyze the project changes and make commit with convential commits format
2 | Make a commit explaining why the changes were made and what's new
3 | Commit only changed files, not all and not new(let add it manually)
4 |
--------------------------------------------------------------------------------
/public/icons/new-line.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "astro/tsconfigs/strict",
3 | "include": [".astro/types.d.ts", "**/*"],
4 | "exclude": ["dist"],
5 | "compilerOptions": {
6 | "jsx": "preserve",
7 | "experimentalDecorators": true,
8 | "useDefineForClassFields": false
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/public/icons/time.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/db/config.ts:
--------------------------------------------------------------------------------
1 | // import { defineDb, defineTable, column, NOW } from "astro:db";
2 |
3 | // export const Users = defineTable({
4 | // columns: {
5 | // id: column.number({ primaryKey: true }),
6 | // type: column.text(),
7 | // },
8 | // });
9 |
10 | // export default defineDb({
11 | // tables: { Users },
12 | // });
13 |
--------------------------------------------------------------------------------
/public/icons/send.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/lib/auth-client.ts:
--------------------------------------------------------------------------------
1 | import { createAuthClient } from "better-auth/react";
2 | import { passkeyClient } from "better-auth/client/plugins";
3 |
4 | export const authClient = createAuthClient({
5 | baseURL: import.meta.env.ASTRO_PUBLIC_APP_URL,
6 | plugins: [passkeyClient()],
7 | });
8 |
9 | export const { signIn, signOut, signUp, useSession } = authClient;
10 |
--------------------------------------------------------------------------------
/public/icons/wallet.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/icons/chat.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # build output
2 | dist/
3 | # generated types
4 | .astro/
5 |
6 | # dependencies
7 | node_modules/
8 |
9 | # logs
10 | npm-debug.log*
11 | yarn-debug.log*
12 | yarn-error.log*
13 | pnpm-debug.log*
14 |
15 |
16 | # environment variables
17 | .env
18 | .env.production
19 |
20 | # macOS-specific files
21 | .DS_Store
22 |
23 | # jetbrains setting folder
24 | .idea/
25 |
26 | # data
27 | .meta/data
28 |
--------------------------------------------------------------------------------
/src/pages/api/auth/[...all].ts:
--------------------------------------------------------------------------------
1 | import type { APIRoute } from "astro";
2 | import { auth } from "../../../lib/auth-server";
3 |
4 | export const prerender = false;
5 |
6 | export const ALL: APIRoute = async (ctx) => {
7 | // If you want to use rate limiting, make sure to set the 'x-forwarded-for' header to the request headers from the context
8 | // ctx.request.headers.set("x-forwarded-for", ctx.clientAddress);
9 | return auth.handler(ctx.request);
10 | };
11 |
--------------------------------------------------------------------------------
/src/lib/auth-server.ts:
--------------------------------------------------------------------------------
1 | import { betterAuth } from "better-auth";
2 | import Database from "better-sqlite3";
3 | import { passkey } from "better-auth/plugins/passkey";
4 |
5 | export const auth = betterAuth({
6 | socialProviders: {
7 | github: {
8 | clientId: process.env.GITHUB_CLIENT_ID!,
9 | clientSecret: process.env.GITHUB_CLIENT_SECRET!,
10 | },
11 | },
12 |
13 | plugins: [passkey()],
14 | database: new Database(".meta/data/content.db"),
15 | });
16 |
--------------------------------------------------------------------------------
/.meta/rules/README.md:
--------------------------------------------------------------------------------
1 | # Qwen Rules Configuration
2 |
3 | This project uses basic rules for the Qwen AI assistant, which are stored in the `.meta/rules/` directory.
4 |
5 | ## Rules Location
6 | - Basic rules: `.meta/rules/basic_rules.md`
7 |
8 | ## Purpose
9 | These rules guide the Qwen assistant's behavior when working on this project, ensuring consistent and appropriate interactions.
10 |
11 | ## Reference
12 | For more information about these rules, see the files in `.meta/rules/`.
--------------------------------------------------------------------------------
/public/icons/key.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/uno.config.ts:
--------------------------------------------------------------------------------
1 | import {
2 | defineConfig,
3 | presetAttributify,
4 | transformerCompileClass,
5 | presetWebFonts,
6 | } from "unocss";
7 | // import { createLocalFontProcessor } from "@unocss/preset-web-fonts/local";
8 | import presetWind4 from "@unocss/preset-wind4";
9 |
10 | export default defineConfig({
11 | presets: [
12 | presetWind4({
13 | preflights: {
14 | theme: true,
15 | },
16 | }),
17 | presetAttributify(),
18 | presetWebFonts({
19 | provider: "none",
20 | fonts: {
21 | sans: "Inter",
22 | mono: "JetBrains Mono",
23 | },
24 | }),
25 | ],
26 | transformers: [transformerCompileClass()],
27 | });
28 |
--------------------------------------------------------------------------------
/astro.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "astro/config";
2 |
3 | //@ts-ignore
4 | import lit from "@semantic-ui/astro-lit";
5 | import vue from "@astrojs/vue";
6 | import UnoCSS from "unocss/astro";
7 |
8 | import node from "@astrojs/node";
9 | import db from "@astrojs/db";
10 |
11 | // https://astro.build/config
12 | export default defineConfig({
13 | integrations: [
14 | vue({
15 | template: {
16 | compilerOptions: {
17 | isCustomElement: (tag) => tag.startsWith("wa-"),
18 | },
19 | },
20 | }),
21 | lit(),
22 | UnoCSS({
23 | injectReset: true,
24 | }),
25 | db(),
26 | ],
27 | adapter: node({
28 | mode: "standalone",
29 | }),
30 | });
31 |
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/public/icons/github.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/src/components/Sign/Sign.vue:
--------------------------------------------------------------------------------
1 |
27 |
28 |
29 |
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/src/layouts/Base.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import "@awesome.me/webawesome/dist/styles/webawesome.css";
3 | import "@awesome.me/webawesome/dist/styles/themes/shoelace.css";
4 | import "@fontsource-variable/inter";
5 | import "../styles/global.css";
6 | ---
7 |
8 |
12 |
13 |
14 |
15 |
16 |
17 | Astro
18 |
19 |
20 |
21 |
22 |
23 |
24 |
30 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "fenchon",
3 | "type": "module",
4 | "version": "0.0.1",
5 | "scripts": {
6 | "dev": "astro dev --remote",
7 | "build": "astro build --remote",
8 | "start": "astro preview",
9 | "test:puppeteer": "bun run tests/help-forum-puppeteer-test.ts"
10 | },
11 | "dependencies": {
12 | "@astrojs/db": "^0.18.0",
13 | "@astrojs/node": "^9.4.4",
14 | "@astrojs/vue": "^5.1.1",
15 | "@awesome.me/webawesome": "^3.0.0-beta.6",
16 | "@fontsource-variable/inter": "^5.2.8",
17 | "@semantic-ui/astro-lit": "^5.1.1",
18 | "astro": "^5.14.1",
19 | "better-auth": "^1.3.18",
20 | "better-sqlite3": "^12.4.1",
21 | "lit": "^3.3.1",
22 | "vue": "^3.5.22"
23 | },
24 | "devDependencies": {
25 | "@playwright/test": "^1.57.0",
26 | "@types/better-sqlite3": "^7.6.13",
27 | "@unocss/preset-attributify": "^66.5.2",
28 | "@unocss/preset-wind4": "^66.5.2",
29 | "@unocss/reset": "^66.5.2",
30 | "@unocss/transformer-compile-class": "^66.5.2",
31 | "puppeteer": "^24.31.0",
32 | "unocss": "^66.5.2"
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/.meta/rules/basic_rules.md:
--------------------------------------------------------------------------------
1 | # Basic Rules for Qwen Assistant
2 |
3 | ## Core Principles
4 | 1. Be helpful and provide accurate information
5 | 2. Follow ethical guidelines and avoid harmful content
6 | 3. Respect user privacy and data security
7 | 4. Maintain professionalism in all interactions
8 |
9 | ## Communication Guidelines
10 | 1. Be concise and clear in responses
11 | 2. Use appropriate formatting (markdown) when needed
12 | 3. Ask for clarification if a request is ambiguous
13 | 4. Provide actionable and relevant information
14 |
15 | ## Technical Guidelines
16 | 1. Follow project conventions and coding standards
17 | 2. Prefer existing project libraries and tools over new dependencies
18 | 3. Verify code changes with appropriate tests
19 | 4. Adhere to security best practices
20 | 5. Always research and understand the existing codebase before making changes
21 |
22 | ## File Handling
23 | 1. Always use absolute paths for file operations
24 | 2. Respect gitignore patterns when searching files
25 | 3. Backup important files before making modifications
26 | 4. Verify file operations before completing tasks
27 |
28 | ## Safety Rules
29 | 1. Never execute potentially dangerous commands without user confirmation
30 | 2. Avoid making changes that could compromise system security
31 | 3. Be cautious with file system modifications
32 | 4. Validate all inputs before using them in operations
--------------------------------------------------------------------------------
/src/pages/index.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import Base from "../layouts/Base.astro";
3 | import SignIn from "../components/Sign/Sign.vue";
4 | import t from "../components/Sign/translations/en.json";
5 | import IssueCard from "../components/IssueCard/IssueCard.vue";
6 | ---
7 |
8 |
9 |
10 |
11 |
12 |
13 | {t.signIn}
14 |
15 |
16 | {/* @ts-ignore – allow custom attributes on PascalCase wrapper */}
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
39 |
40 |
48 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Astro Starter Kit: Minimal
2 |
3 | ```sh
4 | bun create astro@latest -- --template minimal
5 | ```
6 |
7 | > 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
8 |
9 | ## 🚀 Project Structure
10 |
11 | Inside of your Astro project, you'll see the following folders and files:
12 |
13 | ```text
14 | /
15 | ├── public/
16 | ├── src/
17 | │ └── pages/
18 | │ └── index.astro
19 | └── package.json
20 | ```
21 |
22 | Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
23 |
24 | There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
25 |
26 | Any static assets, like images, can be placed in the `public/` directory.
27 |
28 | ## 🧞 Commands
29 |
30 | All commands are run from the root of the project, from a terminal:
31 |
32 | | Command | Action |
33 | | :------------------------ | :----------------------------------------------- |
34 | | `bun install` | Installs dependencies |
35 | | `bun dev` | Starts local dev server at `localhost:4321` |
36 | | `bun build` | Build your production site to `./dist/` |
37 | | `bun preview` | Preview your build locally, before deploying |
38 | | `bun astro ...` | Run CLI commands like `astro add`, `astro check` |
39 | | `bun astro -- --help` | Get help using the Astro CLI |
40 |
41 | ## 👀 Want to learn more?
42 |
43 | Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
44 |
--------------------------------------------------------------------------------
/src/components/Header/wa-header.ts:
--------------------------------------------------------------------------------
1 | import { LitElement, html, css } from "lit";
2 | import { customElement, property } from "lit/decorators.js";
3 |
4 | @customElement("wa-header")
5 | export class WaHeader extends LitElement {
6 | static styles = css`
7 | :host {
8 | display: block;
9 | position: fixed;
10 | top: 0;
11 | left: 0;
12 | right: 0;
13 | height: 60px;
14 | background: #ffffff;
15 | border-bottom: 1px solid #e0e0e0;
16 | z-index: 1000;
17 | font-family: var(--wa-font-family, inherit);
18 | }
19 |
20 | .header-content {
21 | display: flex;
22 | justify-content: space-between;
23 | align-items: center;
24 | height: 100%;
25 | max-width: 1200px;
26 | margin: 0 auto;
27 | padding: 0 20px;
28 | box-sizing: border-box;
29 | }
30 |
31 | .header-title {
32 | font-size: 1.5rem;
33 | font-weight: bold;
34 | color: #333;
35 | }
36 |
37 | .settings-section {
38 | display: flex;
39 | align-items: center;
40 | gap: 10px;
41 | }
42 |
43 | .settings-label {
44 | font-size: 0.875rem;
45 | color: #666;
46 | white-space: nowrap;
47 | }
48 |
49 | wa-special-combobox {
50 | min-width: 120px;
51 | }
52 |
53 | @media (max-width: 768px) {
54 | .header-content {
55 | padding: 0 16px;
56 | }
57 |
58 | .header-title {
59 | font-size: 1.25rem;
60 | }
61 |
62 | .settings-section {
63 | gap: 8px;
64 | }
65 |
66 | .settings-label {
67 | display: none;
68 | }
69 | }
70 | `;
71 |
72 | @property({ type: String })
73 | title: string = "Fenchon";
74 |
75 | render() {
76 | return html`
77 |
93 | `;
94 | }
95 | }
96 |
97 | declare global {
98 | interface HTMLElementTagNameMap {
99 | "wa-header": WaHeader;
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/src/components/Sign/SignCard.vue:
--------------------------------------------------------------------------------
1 |
32 |
33 |
34 |
35 |
87 |
88 |
89 |
90 |
99 |
--------------------------------------------------------------------------------
/src/components/col-comb/col-comb.styles.css.ts:
--------------------------------------------------------------------------------
1 | import { css } from "lit";
2 |
3 | export default css`
4 | :host {
5 | display: inline-block;
6 | position: relative;
7 | font-family: var(--wa-font-family, inherit);
8 | width: 100%;
9 | }
10 |
11 | .col-comb-wrapper {
12 | display: flex;
13 | flex-direction: column;
14 | }
15 |
16 | wa-input {
17 | display: block;
18 | width: 100%;
19 | --wa-focus-ring: none;
20 | }
21 |
22 | .multiline-input {
23 | width: 100%;
24 | padding: 0.5rem 1rem;
25 | border: 1px solid #d1d5db; /* Tailwind gray-300 */
26 | border-radius: 0.375rem;
27 | font-size: 0.9rem;
28 | font-family: var(--wa-font-family, inherit);
29 | background-color: #ffffff;
30 | resize: vertical;
31 | min-height: 40px;
32 | max-height: 200px;
33 | }
34 |
35 | .multiline-input:focus {
36 | outline: 2px solid #3b82f6; /* Tailwind blue-500 */
37 | outline-offset: 2px;
38 | }
39 |
40 | .listbox {
41 | position: absolute;
42 | top: 100%;
43 | left: 0;
44 | right: 0;
45 | background: white;
46 | border: 1px solid #d1d5db; /* Tailwind gray-300 */
47 | border-top: 0;
48 | border-radius: 0 0 0.375rem 0.375rem;
49 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
50 | max-height: 200px;
51 | overflow-y: auto;
52 | z-index: 10;
53 | opacity: 0;
54 | transform: translateY(-4px);
55 | transition:
56 | opacity 0.15s ease,
57 | transform 0.15s ease;
58 | pointer-events: none;
59 | }
60 |
61 | :host(.open) .listbox {
62 | opacity: 1;
63 | transform: translateY(0);
64 | pointer-events: auto;
65 | }
66 |
67 | .listbox-option {
68 | padding: 0.5rem;
69 | cursor: pointer;
70 | border-bottom: 1px solid #e5e7eb; /* Tailwind gray-200 */
71 | }
72 |
73 | .listbox-option:last-child {
74 | border-bottom: none;
75 | }
76 |
77 | .listbox-option:hover,
78 | .listbox-option.active {
79 | background-color: #f3f4f6; /* Tailwind gray-100 */
80 | }
81 |
82 | .option-title {
83 | font-weight: 600;
84 | margin: 0 0 0.125rem 0;
85 | color: #1f2937; /* Tailwind gray-800 */
86 | font-size: 0.9rem;
87 | }
88 |
89 | .option-description {
90 | font-size: 0.75rem;
91 | margin: 0;
92 | color: #6b7280; /* Tailwind gray-600 */
93 | }
94 |
95 | .create-option {
96 | padding: 0.75rem;
97 | border-bottom: 1px solid #e5e7eb; /* Tailwind gray-200 */
98 | cursor: pointer;
99 | font-weight: 600;
100 | }
101 |
102 | .create-option:hover {
103 | background-color: #f3f4f6; /* Tailwind gray-100 */
104 | }
105 |
106 | /* Styles for slotted content */
107 | .listbox > div,
108 | .listbox ::slotted(div) {
109 | padding: 8px 12px;
110 | cursor: pointer;
111 | }
112 |
113 | .listbox > div:hover,
114 | .listbox > div.active,
115 | .listbox ::slotted(div):hover,
116 | .listbox ::slotted(.active) {
117 | background-color: #f3f4f6; /* Tailwind gray-100 */
118 | }
119 |
120 | .actions {
121 | display: flex;
122 | gap: 1rem;
123 | margin-top: 0.5rem;
124 | }
125 |
126 | .action-btn {
127 | display: flex;
128 | align-items: center;
129 | gap: 0.5rem;
130 | background: none;
131 | border: none;
132 | color: var(--colors-zinc-600, #6b7177);
133 | cursor: pointer;
134 | font-size: 0.875rem;
135 | padding: 0;
136 | }
137 |
138 | .action-btn:hover {
139 | color: var(--colors-zinc-900, #1d2025);
140 | }
141 |
142 | kbd {
143 | background-color: var(--colors-zinc-100, #f0f1f2);
144 | padding: 0.125rem 0.25rem;
145 | border-radius: 0.25rem;
146 | font-family: monospace;
147 | font-size: 0.75rem;
148 | }
149 |
150 | .icon {
151 | font-size: 1em;
152 | /* This works with wa-icon or text-based icons */
153 | }
154 | `;
155 |
--------------------------------------------------------------------------------
/src/components/AskInput/AskInput.vue:
--------------------------------------------------------------------------------
1 |
31 |
32 |
33 |
34 |
42 |
43 |
44 |
49 |
54 |
55 |
56 |
57 |
58 |
--------------------------------------------------------------------------------
/src/components/IssueCard/IssueCard.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
36 | Open
39 |
40 |
41 |
42 |
43 |
44 | {{ ticket.summary }}
45 |
46 |
47 |
48 |
49 | {{ ticket.content }}
50 |
51 |
52 |
53 |
54 |
61 | {{ topic }}
62 |
63 |
64 |
65 |
83 |
91 |
92 |
93 |
94 |
150 |
151 |
245 |
--------------------------------------------------------------------------------
/src/components/col-comb/col-comb.ts:
--------------------------------------------------------------------------------
1 | import { LitElement, html, css } from "lit";
2 | import { customElement, property, state } from "lit/decorators.js";
3 | import colCombStyles from "./col-comb.styles.css.ts";
4 |
5 | export interface ColOption {
6 | id?: number;
7 | label: string;
8 | value: string;
9 | description?: string;
10 | }
11 |
12 | @customElement("col-comb")
13 | export class ColComb extends LitElement {
14 | static styles = colCombStyles;
15 |
16 | /**
17 | * The selected value of the combobox
18 | */
19 | @property({ type: String })
20 | value?: string;
21 |
22 | /**
23 | * Placeholder text for the input field
24 | */
25 | @property({ type: String })
26 | placeholder: string = "Search...";
27 |
28 | /**
29 | * Array of options to display in the combobox
30 | */
31 | @property({ type: Array })
32 | options: ColOption[] = [];
33 |
34 | /**
35 | * Whether to allow creating new options
36 | */
37 | @property({ type: Boolean })
38 | allowCreate: boolean = false;
39 |
40 | /**
41 | * Whether to show all options without filtering
42 | */
43 | @property({ type: Boolean })
44 | showAll: boolean = false;
45 |
46 | /**
47 | * Filtered options based on the current input value
48 | */
49 | @state()
50 | private filteredOptions: ColOption[] = [];
51 |
52 | /**
53 | * Whether the dropdown listbox is open
54 | */
55 | @state()
56 | private open: boolean = false;
57 |
58 | /**
59 | * Index of the currently active option for keyboard navigation
60 | */
61 | @state()
62 | private activeIndex: number = -1;
63 |
64 | /**
65 | * Whether the component has slotted content
66 | */
67 | @state()
68 | private hasSlotContent: boolean = false;
69 |
70 | /**
71 | * Whether the input field is in multiline mode
72 | */
73 | @state()
74 | private isMultiline: boolean = false;
75 |
76 | private handleDocumentClick = (event: MouseEvent) => {
77 | if (!this.contains(event.target as Node)) {
78 | this.open = false;
79 | this.activeIndex = -1;
80 | }
81 | };
82 |
83 | connectedCallback() {
84 | super.connectedCallback();
85 | document.addEventListener("click", this.handleDocumentClick);
86 | this.addEventListener("click", this.handleHostClick);
87 | const slot = this.shadowRoot?.querySelector("slot") as HTMLSlotElement;
88 | if (slot) {
89 | slot.addEventListener("slotchange", this.handleSlotChange.bind(this));
90 | this.handleSlotChange();
91 | }
92 | }
93 |
94 | disconnectedCallback() {
95 | super.disconnectedCallback();
96 | document.removeEventListener("click", this.handleDocumentClick);
97 | this.removeEventListener("click", this.handleHostClick);
98 | const slot = this.shadowRoot?.querySelector("slot") as HTMLSlotElement;
99 | if (slot) {
100 | slot.removeEventListener("slotchange", this.handleSlotChange.bind(this));
101 | }
102 | }
103 |
104 | private updateFiltered() {
105 | if (this.hasSlotContent) return;
106 |
107 | const filter = this.showAll ? "" : (this.value || "").toLowerCase();
108 | this.filteredOptions = this.options.filter(
109 | (option) =>
110 | option.label.toLowerCase().includes(filter) ||
111 | (option.description &&
112 | option.description.toLowerCase().includes(filter)),
113 | );
114 | }
115 |
116 | private handleInput(e: CustomEvent) {
117 | this.value = e.detail?.value ?? "";
118 |
119 | if (!this.hasSlotContent) {
120 | this.updateFiltered();
121 | }
122 |
123 | if (
124 | !this.allowCreate &&
125 | !this.hasSlotContent &&
126 | !this.filteredOptions.length
127 | ) {
128 | this.open = false;
129 | } else {
130 | this.open = true;
131 | if (!this.hasSlotContent) {
132 | this.activeIndex = 0;
133 | }
134 | }
135 | }
136 |
137 | private handleFocus(e: FocusEvent) {
138 | if (!this.hasSlotContent) {
139 | this.updateFiltered();
140 | }
141 | if (!this.open) {
142 | this.open = true;
143 | if (!this.hasSlotContent && this.filteredOptions.length > 0) {
144 | this.activeIndex = 0;
145 | }
146 | }
147 | }
148 |
149 | private handleClick(e: MouseEvent) {
150 | e.stopPropagation();
151 | if (!this.hasSlotContent) {
152 | this.updateFiltered();
153 | }
154 | if (!this.open) {
155 | this.open = true;
156 | if (!this.hasSlotContent && this.filteredOptions.length > 0) {
157 | this.activeIndex = 0;
158 | }
159 | }
160 | }
161 |
162 | private handleHostClick = (e: MouseEvent) => {
163 | if ((e.target as Element).closest(".listbox")) return;
164 | if (!this.open) {
165 | this.handleClick(e);
166 | }
167 | };
168 |
169 | private handleKeydown(e: KeyboardEvent) {
170 | // Handle Shift+Enter to switch to multiline mode
171 | if (e.shiftKey && e.key === 'Enter') {
172 | e.preventDefault();
173 | this.handleNewLine();
174 | return;
175 | }
176 |
177 | const keys = ["ArrowUp", "ArrowDown", "Enter", "Escape", "Home", "End"];
178 | if (keys.includes(e.key) && !(e.shiftKey && e.key === 'Enter')) {
179 | e.preventDefault();
180 | }
181 |
182 | if (
183 | !this.hasSlotContent &&
184 | this.filteredOptions.length === 0 &&
185 | !this.allowCreate
186 | )
187 | return;
188 |
189 | if (!this.open && e.key !== "ArrowDown") return;
190 |
191 | let newIndex = this.activeIndex;
192 |
193 | switch (e.key) {
194 | case "ArrowDown":
195 | if (!this.open) {
196 | if (!this.hasSlotContent) this.updateFiltered();
197 | this.open = true;
198 | newIndex = this.hasSlotContent ? -1 : 0;
199 | } else {
200 | if (this.hasSlotContent) {
201 | // For slotted content, we won't manage keyboard navigation
202 | break;
203 | }
204 | if (this.allowCreate) {
205 | // Include the create option in the navigation
206 | const totalItems =
207 | this.filteredOptions.length + (this.allowCreate ? 1 : 0);
208 | newIndex = (this.activeIndex + 1) % totalItems;
209 | } else {
210 | newIndex = (this.activeIndex + 1) % this.filteredOptions.length;
211 | }
212 | }
213 | break;
214 | case "ArrowUp":
215 | if (!this.open) return;
216 | if (this.hasSlotContent) break;
217 | if (this.allowCreate) {
218 | const totalItems =
219 | this.filteredOptions.length + (this.allowCreate ? 1 : 0);
220 | newIndex =
221 | this.activeIndex === 0 ? totalItems - 1 : this.activeIndex - 1;
222 | } else {
223 | newIndex =
224 | this.activeIndex === 0
225 | ? this.filteredOptions.length - 1
226 | : this.activeIndex - 1;
227 | }
228 | break;
229 | case "Home":
230 | if (!this.open) return;
231 | if (this.hasSlotContent) break;
232 | newIndex = 0;
233 | break;
234 | case "End":
235 | if (!this.open) return;
236 | if (this.hasSlotContent) break;
237 | newIndex = this.filteredOptions.length - 1;
238 | break;
239 | case "Enter":
240 | if (this.hasSlotContent && this.activeIndex >= 0) {
241 | // Handle selection for slotted content
242 | const slot = this.shadowRoot?.querySelector(
243 | "slot",
244 | ) as HTMLSlotElement;
245 | if (slot) {
246 | const elements = slot.assignedElements({ flatten: true });
247 | if (elements[this.activeIndex]) {
248 | elements[this.activeIndex].dispatchEvent(
249 | new CustomEvent("click", { bubbles: true }),
250 | );
251 | }
252 | }
253 | } else if (
254 | this.allowCreate &&
255 | this.activeIndex >= this.filteredOptions.length
256 | ) {
257 | // Select create option
258 | this.createOption(this.value || "");
259 | } else if (this.activeIndex >= 0 && this.open && !this.hasSlotContent) {
260 | this.selectOption(this.activeIndex);
261 | }
262 | break;
263 | case "Escape":
264 | this.open = false;
265 | this.activeIndex = -1;
266 | return;
267 | default:
268 | return;
269 | }
270 |
271 | this.activeIndex = newIndex;
272 | }
273 |
274 | /**
275 | * Dispatched when an option is selected from the dropdown
276 | * @event option-selected
277 | * @param {Object} detail - Event details
278 | * @param {ColOption} detail.option - The selected option object
279 | * @param {string} detail.value - The value of the selected option
280 | */
281 | private selectOption(index: number) {
282 | if (this.hasSlotContent) return;
283 | const option = this.filteredOptions[index];
284 | if (option) {
285 | this.value = option.value || option.label;
286 | this.open = false;
287 | this.activeIndex = -1;
288 |
289 | this.dispatchEvent(
290 | new CustomEvent("option-selected", {
291 | detail: { option, value: option.value || option.label },
292 | bubbles: true,
293 | composed: true,
294 | }),
295 | );
296 | }
297 | }
298 |
299 | /**
300 | * Dispatched when a new option is created
301 | * @event option-created
302 | * @param {Object} detail - Event details
303 | * @param {string} detail.query - The query string that was used to create the new option
304 | */
305 | private createOption(query: string) {
306 | this.open = false;
307 | this.activeIndex = -1;
308 |
309 | this.dispatchEvent(
310 | new CustomEvent("option-created", {
311 | detail: { query },
312 | bubbles: true,
313 | composed: true,
314 | }),
315 | );
316 |
317 | this.value = query;
318 | }
319 |
320 | private async handleNewLine() {
321 | // Toggle multiline mode
322 | this.isMultiline = true;
323 |
324 | // Add a newline to the current value if there's content
325 | if (this.value) {
326 | this.value = this.value + '\n';
327 | } else {
328 | this.value = '\n';
329 | }
330 |
331 | // Wait for the update to complete before focusing
332 | await this.updateComplete;
333 |
334 | // Dispatch a custom event for the new line action
335 | this.dispatchEvent(
336 | new CustomEvent("new-line", {
337 | detail: { isMultiline: this.isMultiline, value: this.value },
338 | bubbles: true,
339 | composed: true,
340 | }),
341 | );
342 |
343 | // Focus the input after switching to textarea
344 | const inputElement = this.renderRoot?.querySelector('#combobox-input') as HTMLTextAreaElement;
345 | if (inputElement) {
346 | inputElement.focus();
347 | // Move cursor to the end
348 | inputElement.selectionStart = inputElement.selectionEnd = inputElement.value.length;
349 | }
350 | }
351 |
352 | private handleTextareaInput(e: Event) {
353 | const target = e.target as HTMLTextAreaElement;
354 | this.value = target.value;
355 |
356 | // If the textarea becomes empty, switch back to single-line mode
357 | if (this.value === '') {
358 | this.isMultiline = false;
359 | }
360 |
361 | if (!this.hasSlotContent) {
362 | this.updateFiltered();
363 | }
364 |
365 | if (!this.allowCreate && !this.hasSlotContent && !this.filteredOptions.length) {
366 | this.open = false;
367 | } else {
368 | this.open = true;
369 | if (!this.hasSlotContent) {
370 | this.activeIndex = 0;
371 | }
372 | }
373 | }
374 |
375 | private handleTextareaKeydown(e: KeyboardEvent) {
376 | // Handle Shift+Enter to add a new line in the textarea
377 | if (e.shiftKey && e.key === 'Enter') {
378 | e.preventDefault();
379 | const textarea = e.target as HTMLTextAreaElement;
380 | const start = textarea.selectionStart || 0;
381 | const end = textarea.selectionEnd || 0;
382 | const text = this.value || '';
383 | const newValue = text.substring(0, start) + '\n' + text.substring(end);
384 |
385 | this.value = newValue;
386 | textarea.value = newValue;
387 |
388 | // Update the textarea value and position cursor after the newline
389 | setTimeout(() => {
390 | textarea.selectionStart = textarea.selectionEnd = start + 1;
391 | }, 0);
392 |
393 | return;
394 | }
395 |
396 | // For regular Enter without Shift, if dropdown is open, it should select the option
397 | if (e.key === 'Enter' && !e.shiftKey) {
398 | if (this.open && this.activeIndex >= 0 && !this.hasSlotContent && this.filteredOptions.length > 0) {
399 | // This is for option selection, prevent default textarea behavior
400 | e.preventDefault();
401 |
402 | // Manually handle the option selection logic similar to the original handler
403 | if (this.allowCreate && this.activeIndex >= this.filteredOptions.length) {
404 | // Select create option
405 | this.createOption(this.value || "");
406 | } else if (this.activeIndex >= 0 && this.open && !this.hasSlotContent) {
407 | this.selectOption(this.activeIndex);
408 | }
409 | }
410 | // If dropdown is closed, let textarea handle Enter normally for newlines
411 | }
412 | // Arrow keys should work normally in textarea for navigation
413 | else if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) {
414 | // Allow default textarea behavior for navigation
415 | if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
416 | // If we're at the top/bottom of the textarea, we might want to open the dropdown
417 | // For now, just let the textarea handle navigation normally
418 | }
419 | }
420 | // Handle other navigation keys (Escape, etc.) as needed
421 | else if (e.key === 'Escape') {
422 | this.open = false;
423 | this.activeIndex = -1;
424 | }
425 | // Handle Backspace when at the beginning of an empty text to potentially convert back to single-line
426 | else if (e.key === 'Backspace') {
427 | const textarea = e.target as HTMLTextAreaElement;
428 | const cursorPos = textarea.selectionStart;
429 | const textValue = textarea.value;
430 |
431 | // If we're at the start of a single character text, and it gets deleted, convert back to single-line
432 | // Check if after deletion the text will be empty (when we're at position 1 of 1-char string)
433 | if (textValue.length === 1 && cursorPos === 1) {
434 | // After backspace, text will be empty, so convert back to single-line mode
435 | setTimeout(() => {
436 | if (textarea.value === '') {
437 | this.isMultiline = false;
438 | this.requestUpdate();
439 | }
440 | }, 0);
441 | }
442 | // Also handle the case where we backspace the last character
443 | else if (textValue.length === 1 && cursorPos === 0) {
444 | // If cursor is at position 0 and there's 1 char (like if user moved to start), also check
445 | setTimeout(() => {
446 | if (textarea.value === '') {
447 | this.isMultiline = false;
448 | this.requestUpdate();
449 | }
450 | }, 0);
451 | }
452 | }
453 | }
454 |
455 | private handleSend() {
456 | // Dispatch a custom event for the send action
457 | this.dispatchEvent(
458 | new CustomEvent("send", {
459 | bubbles: true,
460 | composed: true,
461 | }),
462 | );
463 | }
464 |
465 | private handleSlotChange() {
466 | const slot = this.shadowRoot?.querySelector("slot") as HTMLSlotElement;
467 | if (slot) {
468 | this.hasSlotContent = slot.assignedElements({ flatten: true }).length > 0;
469 | this.requestUpdate();
470 | }
471 | }
472 |
473 | protected updated(changedProperties: Map) {
474 | super.updated(changedProperties);
475 |
476 | if (changedProperties.has("open")) {
477 | if (this.open) {
478 | this.classList.add("open");
479 | } else {
480 | this.classList.remove("open");
481 | }
482 | }
483 |
484 | if (
485 | changedProperties.has("options") ||
486 | changedProperties.has("value") ||
487 | changedProperties.has("showAll")
488 | ) {
489 | this.updateFiltered();
490 | if (this.open && this.activeIndex < 0 && !this.hasSlotContent) {
491 | this.activeIndex = 0;
492 | }
493 | }
494 |
495 | if (changedProperties.has("hasSlotContent")) {
496 | if (this.hasSlotContent && !this.open) {
497 | this.open = true;
498 | }
499 | }
500 | }
501 |
502 | protected createRenderRoot() {
503 | return this;
504 | }
505 |
506 | private renderListbox() {
507 | const showCreateOption =
508 | this.allowCreate &&
509 | this.value &&
510 | !this.filteredOptions.length &&
511 | !this.hasSlotContent;
512 |
513 | return html`
514 |
520 |
521 | ${!this.hasSlotContent
522 | ? html`
523 | ${this.filteredOptions.map(
524 | (option, index) => html`
525 |
{
532 | this.selectOption(index);
533 | }}"
534 | tabindex="-1"
535 | >
536 | ${option.description
537 | ? html`
538 |
${option.label}
539 |
540 | ${option.description}
541 |
542 | `
543 | : option.label}
544 |
545 | `,
546 | )}
547 | ${showCreateOption
548 | ? html`
549 |
this.createOption(this.value || "")}"
557 | tabindex="-1"
558 | >
559 | Create new: "${this.value}"
560 |
561 | `
562 | : ""}
563 | `
564 | : ""}
565 |
566 | `;
567 | }
568 |
569 | render() {
570 | const activeDescendant =
571 | this.activeIndex >= 0 ? `option-${this.activeIndex}` : "";
572 |
573 | return html`
574 |
575 | ${this.isMultiline
576 | ? html`
577 |
593 | `
594 | : html`
595 |
611 | `
612 | }
613 | ${this.renderListbox()}
614 |
615 |
620 |
625 |
626 |
627 | `;
628 | }
629 | }
630 |
631 | declare global {
632 | interface HTMLElementTagNameMap {
633 | "col-comb": ColComb;
634 | }
635 | }
636 |
--------------------------------------------------------------------------------