├── 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 | 2 | 3 | 4 | 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 | 2 | 3 | 9 | 10 | -------------------------------------------------------------------------------- /public/icons/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/components/Sign/Sign.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 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 |
78 |
${this.title}
79 |
80 | Theme: 81 | 85 | 86 | 87 | 88 | 89 | 90 | 91 |
92 |
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 | 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 | 57 | 58 | -------------------------------------------------------------------------------- /src/components/IssueCard/IssueCard.vue: -------------------------------------------------------------------------------- 1 | 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 |
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 |
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 | --------------------------------------------------------------------------------