-
Notifications
You must be signed in to change notification settings - Fork 0
feat(web): pull the four new design-system primitives into the app (HT-93) #103
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| export interface CommandMenuItem { | ||
| label: string | ||
| snippet?: string | ||
| keywords?: string | ||
| shortcut?: string | ||
| } | ||
| export interface CommandMenuProps { | ||
| items?: CommandMenuItem[] | ||
| placeholder?: string | ||
| onPick?: (item: CommandMenuItem) => void | ||
| inline?: boolean | ||
| width?: number | ||
| initialQuery?: string | ||
| } | ||
| export declare function CommandMenu(props: CommandMenuProps): JSX.Element | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,225 @@ | ||||||||||||||
| import React from 'react' | ||||||||||||||
| import { IconReply, IconSearch } from './primitives-support' | ||||||||||||||
|
|
||||||||||||||
| /** Searchable inserter for saved replies. Filters as you type; | ||||||||||||||
| * ↑↓ moves, ↵ inserts, esc clears. */ | ||||||||||||||
| export function CommandMenu({ | ||||||||||||||
| items = [], | ||||||||||||||
| placeholder = 'Search saved replies…', | ||||||||||||||
| onPick, | ||||||||||||||
| inline, | ||||||||||||||
| width = 320, | ||||||||||||||
| initialQuery = '', | ||||||||||||||
| }) { | ||||||||||||||
| const [q, setQ] = React.useState(initialQuery) | ||||||||||||||
| const [hi, setHi] = React.useState(0) | ||||||||||||||
| const inputRef = React.useRef(null) | ||||||||||||||
| const listRef = React.useRef(null) | ||||||||||||||
|
|
||||||||||||||
| const filtered = React.useMemo(() => { | ||||||||||||||
| const s = q.trim().toLowerCase() | ||||||||||||||
| if (!s) return items | ||||||||||||||
| return items.filter((it) => | ||||||||||||||
| `${it.label} ${it.snippet || ''} ${it.keywords || ''}`.toLowerCase().includes(s), | ||||||||||||||
| ) | ||||||||||||||
| }, [q, items]) | ||||||||||||||
|
|
||||||||||||||
| // `q` is the trigger here, not a read: the highlight resets to the top of the | ||||||||||||||
| // list whenever the query changes. | ||||||||||||||
| // biome-ignore lint/correctness/useExhaustiveDependencies: q is a trigger, not a read | ||||||||||||||
| React.useEffect(() => { | ||||||||||||||
| setHi(0) | ||||||||||||||
| }, [q]) | ||||||||||||||
|
|
||||||||||||||
| React.useEffect(() => { | ||||||||||||||
| const el = listRef.current?.children[hi] | ||||||||||||||
| if (el?.scrollIntoViewIfNeeded) { | ||||||||||||||
| el.scrollIntoViewIfNeeded() | ||||||||||||||
| } else if (el) { | ||||||||||||||
| const p = listRef.current | ||||||||||||||
| if (el.offsetTop < p.scrollTop) p.scrollTop = el.offsetTop | ||||||||||||||
| else if (el.offsetTop + el.offsetHeight > p.scrollTop + p.clientHeight) | ||||||||||||||
| p.scrollTop = el.offsetTop + el.offsetHeight - p.clientHeight | ||||||||||||||
| } | ||||||||||||||
| }, [hi]) | ||||||||||||||
|
|
||||||||||||||
| const key = (e) => { | ||||||||||||||
| if (e.key === 'ArrowDown') { | ||||||||||||||
| e.preventDefault() | ||||||||||||||
| setHi((i) => Math.min(i + 1, filtered.length - 1)) | ||||||||||||||
|
Comment on lines
+47
to
+49
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Keep the highlighted index non-negative for empty results. Line 49 sets Proposed fix- setHi((i) => Math.min(i + 1, filtered.length - 1))
+ setHi((i) => Math.max(0, Math.min(i + 1, filtered.length - 1)))📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
| } else if (e.key === 'ArrowUp') { | ||||||||||||||
| e.preventDefault() | ||||||||||||||
| setHi((i) => Math.max(i - 1, 0)) | ||||||||||||||
| } else if (e.key === 'Enter') { | ||||||||||||||
| e.preventDefault() | ||||||||||||||
| const it = filtered[hi] | ||||||||||||||
| if (it) onPick?.(it) | ||||||||||||||
| } else if (e.key === 'Escape') { | ||||||||||||||
| e.preventDefault() | ||||||||||||||
| setQ('') | ||||||||||||||
| e.target.blur() | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| return ( | ||||||||||||||
| <div | ||||||||||||||
| style={{ | ||||||||||||||
| width, | ||||||||||||||
| background: 'var(--ht-surface)', | ||||||||||||||
| border: '1px solid var(--ht-border)', | ||||||||||||||
| borderRadius: 'var(--ht-radius-md)', | ||||||||||||||
| boxShadow: 'var(--ht-shadow-md)', | ||||||||||||||
| overflow: 'hidden', | ||||||||||||||
| animation: inline ? 'none' : 'ht-rise .16s ease-out', | ||||||||||||||
| }} | ||||||||||||||
| > | ||||||||||||||
| <div | ||||||||||||||
| style={{ | ||||||||||||||
| display: 'flex', | ||||||||||||||
| alignItems: 'center', | ||||||||||||||
| gap: 8, | ||||||||||||||
| padding: '9px 12px', | ||||||||||||||
| borderBottom: '1px solid var(--ht-divider)', | ||||||||||||||
| }} | ||||||||||||||
| > | ||||||||||||||
| <span style={{ display: 'inline-flex', color: 'var(--ht-ink-dim)' }}>{IconSearch(15)}</span> | ||||||||||||||
| <input | ||||||||||||||
| ref={inputRef} | ||||||||||||||
| value={q} | ||||||||||||||
| onChange={(e) => setQ(e.target.value)} | ||||||||||||||
| onKeyDown={key} | ||||||||||||||
| placeholder={placeholder} | ||||||||||||||
| // The menu opens in response to an explicit user action and the search | ||||||||||||||
| // field is its entire purpose; not focusing it would strand keyboard users. | ||||||||||||||
| // biome-ignore lint/a11y/noAutofocus: deliberate in the design source | ||||||||||||||
| autoFocus={!inline} | ||||||||||||||
| style={{ | ||||||||||||||
| flex: 1, | ||||||||||||||
| font: 'inherit', | ||||||||||||||
| fontFamily: 'var(--ht-sans)', | ||||||||||||||
| fontSize: 13.5, | ||||||||||||||
| color: 'var(--ht-ink)', | ||||||||||||||
| background: 'none', | ||||||||||||||
| border: 'none', | ||||||||||||||
| outline: 'none', | ||||||||||||||
| padding: 0, | ||||||||||||||
| }} | ||||||||||||||
| /> | ||||||||||||||
| <span | ||||||||||||||
| style={{ | ||||||||||||||
| fontFamily: 'var(--ht-mono)', | ||||||||||||||
| fontSize: 11, | ||||||||||||||
| color: 'var(--ht-ink-dim)', | ||||||||||||||
| border: '1px solid var(--ht-border)', | ||||||||||||||
| borderBottomWidth: 2, | ||||||||||||||
| borderRadius: 'var(--ht-radius-sm)', | ||||||||||||||
| padding: '1px 6px', | ||||||||||||||
| }} | ||||||||||||||
| > | ||||||||||||||
| esc | ||||||||||||||
| </span> | ||||||||||||||
| </div> | ||||||||||||||
| {filtered.length === 0 ? ( | ||||||||||||||
| <div style={{ padding: '34px 20px 30px', textAlign: 'center' }}> | ||||||||||||||
| <div style={{ fontSize: 14.5, fontWeight: 600, color: 'var(--ht-ink)' }}> | ||||||||||||||
| No matching replies | ||||||||||||||
| </div> | ||||||||||||||
| <div | ||||||||||||||
| style={{ | ||||||||||||||
| margin: '6px auto 0', | ||||||||||||||
| maxWidth: 220, | ||||||||||||||
| fontSize: 12.5, | ||||||||||||||
| lineHeight: 1.5, | ||||||||||||||
| color: 'var(--ht-ink-muted)', | ||||||||||||||
| }} | ||||||||||||||
| > | ||||||||||||||
| {`Nothing matches “${q}”. Try a shorter term.`} | ||||||||||||||
| </div> | ||||||||||||||
| </div> | ||||||||||||||
| ) : ( | ||||||||||||||
| <div ref={listRef} style={{ maxHeight: 244, overflowY: 'auto', padding: 5 }}> | ||||||||||||||
| {filtered.map((it, i) => ( | ||||||||||||||
| <button | ||||||||||||||
| key={it.label} | ||||||||||||||
| type="button" | ||||||||||||||
| onClick={() => onPick?.(it)} | ||||||||||||||
| onMouseMove={() => setHi(i)} | ||||||||||||||
| style={{ | ||||||||||||||
| display: 'block', | ||||||||||||||
| width: '100%', | ||||||||||||||
| boxSizing: 'border-box', | ||||||||||||||
| textAlign: 'left', | ||||||||||||||
| border: 'none', | ||||||||||||||
| cursor: 'pointer', | ||||||||||||||
| borderRadius: 'var(--ht-radius-sm)', | ||||||||||||||
| padding: '7px 10px', | ||||||||||||||
| background: i === hi ? 'var(--ht-accent-soft)' : 'transparent', | ||||||||||||||
| }} | ||||||||||||||
| > | ||||||||||||||
| <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}> | ||||||||||||||
| <span | ||||||||||||||
| style={{ | ||||||||||||||
| display: 'inline-flex', | ||||||||||||||
| color: i === hi ? 'var(--ht-accent)' : 'var(--ht-ink-dim)', | ||||||||||||||
| }} | ||||||||||||||
| > | ||||||||||||||
| {IconReply(14)} | ||||||||||||||
| </span> | ||||||||||||||
| <span | ||||||||||||||
| style={{ | ||||||||||||||
| flex: 1, | ||||||||||||||
| fontSize: 13, | ||||||||||||||
| fontWeight: 600, | ||||||||||||||
| color: i === hi ? 'var(--ht-accent)' : 'var(--ht-ink)', | ||||||||||||||
| }} | ||||||||||||||
| > | ||||||||||||||
| {it.label} | ||||||||||||||
| </span> | ||||||||||||||
| {it.shortcut ? ( | ||||||||||||||
| <span | ||||||||||||||
| style={{ | ||||||||||||||
| fontFamily: 'var(--ht-mono)', | ||||||||||||||
| fontSize: 11, | ||||||||||||||
| color: 'var(--ht-ink-dim)', | ||||||||||||||
| }} | ||||||||||||||
| > | ||||||||||||||
| {it.shortcut} | ||||||||||||||
| </span> | ||||||||||||||
| ) : null} | ||||||||||||||
| </div> | ||||||||||||||
| {it.snippet ? ( | ||||||||||||||
| <div | ||||||||||||||
| style={{ | ||||||||||||||
| margin: '2px 0 0 24px', | ||||||||||||||
| fontSize: 12, | ||||||||||||||
| lineHeight: 1.4, | ||||||||||||||
| color: 'var(--ht-ink-muted)', | ||||||||||||||
| overflow: 'hidden', | ||||||||||||||
| textOverflow: 'ellipsis', | ||||||||||||||
| whiteSpace: 'nowrap', | ||||||||||||||
| }} | ||||||||||||||
| > | ||||||||||||||
| {it.snippet} | ||||||||||||||
| </div> | ||||||||||||||
| ) : null} | ||||||||||||||
| </button> | ||||||||||||||
| ))} | ||||||||||||||
| </div> | ||||||||||||||
| )} | ||||||||||||||
| <div | ||||||||||||||
| style={{ | ||||||||||||||
| display: 'flex', | ||||||||||||||
| alignItems: 'center', | ||||||||||||||
| gap: 14, | ||||||||||||||
| padding: '7px 12px', | ||||||||||||||
| borderTop: '1px solid var(--ht-divider)', | ||||||||||||||
| fontSize: 11, | ||||||||||||||
| color: 'var(--ht-ink-dim)', | ||||||||||||||
| }} | ||||||||||||||
| > | ||||||||||||||
| <span>{`${filtered.length} ${filtered.length === 1 ? 'reply' : 'replies'}`}</span> | ||||||||||||||
| <span style={{ marginLeft: 'auto', fontFamily: 'var(--ht-mono)' }}>↑↓ move · ↵ insert</span> | ||||||||||||||
| </div> | ||||||||||||||
| </div> | ||||||||||||||
| ) | ||||||||||||||
| } | ||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| export interface Credential { | ||
| name: string | ||
| added: Date | ||
| lastUsed?: Date | null | ||
| } | ||
| export interface CredentialRowProps { | ||
| cred: Credential | ||
| /** Forces a visual state for specimen rendering. */ | ||
| demo?: 'hover' | 'rename' | 'armed' | ||
| onRename?: (cred: Credential, name: string) => void | ||
| onRevoke?: (cred: Credential) => void | ||
| first?: boolean | ||
| } | ||
| export declare function CredentialRow(props: CredentialRowProps): JSX.Element | ||
|
|
||
| export interface PasskeyListProps { | ||
| creds?: Credential[] | ||
| empty?: boolean | ||
| onAdd?: () => void | ||
| } | ||
| export declare function PasskeyList(props: PasskeyListProps): JSX.Element |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🌐 Web query:
Does@types/react19.2.4 expose a global JSX namespace, or should library declaration files import JSX fromreactand useJSX.Element?💡 Result:
No,
@types/react19.2.4 does not expose a global JSX namespace [1][2]. The global JSX namespace was removed in@types/reactversion 19 to prevent type pollution and conflicts with other UI libraries that also utilize JSX [3][4][1]. For library declaration files and general TypeScript code, you should import the JSX namespace directly from React and use React.JSX.Element instead of the previously global JSX.Element [1][5]. You can access it by importing: import { JSX } from 'react'; // Use as const myElement: JSX.Element =Citations:
🏁 Script executed:
Repository: Helpthread/helpthread
Length of output: 4929
🏁 Script executed:
Repository: Helpthread/helpthread
Length of output: 4990
Reference React’s scoped JSX namespace.
web/src/components/ds/core/CommandMenu.d.ts:15should not depend on the ambient globalJSXnamespace. Importtype { JSX } from 'react'and keep the return type asJSX.Elementso this public declaration stays compatible with React 19 consumers.🤖 Prompt for AI Agents