From 4cfe7ed9e9066dc579f29f056c34ec3cbe1a94db Mon Sep 17 00:00:00 2001 From: Aaron Lee Date: Tue, 5 May 2026 17:17:59 -0400 Subject: [PATCH 1/3] feat(sdk-app): replace entity ID inputs with searchable selects Convert employee, contractor, and payroll ID fields in the demo settings panel to two-line dropdowns populated from the API. Make company ID read-only with a copy button. Request and form IDs remain text inputs. Co-Authored-By: Claude Opus 4.7 (1M context) --- sdk-app/src/DemoSettingsPanel.module.scss | 121 ++++++++ sdk-app/src/DemoSettingsPanel.tsx | 338 +++++++++++++++++++++- 2 files changed, 447 insertions(+), 12 deletions(-) diff --git a/sdk-app/src/DemoSettingsPanel.module.scss b/sdk-app/src/DemoSettingsPanel.module.scss index 3b43856b8..0eb44bdb5 100644 --- a/sdk-app/src/DemoSettingsPanel.module.scss +++ b/sdk-app/src/DemoSettingsPanel.module.scss @@ -166,3 +166,124 @@ gap: 0.5rem; margin-top: 0.5rem; } + +.copyRow { + display: flex; + gap: 0.5rem; + align-items: stretch; + + input { + flex: 1; + min-width: 0; + } +} + +.readOnly { + background: var(--color-badge-bg); + cursor: default; +} + +.selectWrapper { + position: relative; +} + +.selectButton { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.5rem 0.625rem; + border: 0.0625rem solid var(--color-border); + border-radius: 0.375rem; + background: var(--color-bg); + color: var(--color-text); + font-size: 0.8125rem; + cursor: pointer; + text-align: left; + + &:focus { + outline: none; + border-color: var(--color-active); + box-shadow: 0 0 0 0.125rem var(--color-focus-ring); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} + +.selectButtonContent { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; +} + +.selectPlaceholder { + color: var(--color-text-muted); + flex: 1; +} + +.selectChevron { + color: var(--color-text-muted); + font-size: 0.75rem; + flex-shrink: 0; +} + +.selectMenu { + position: absolute; + top: calc(100% + 0.25rem); + left: 0; + right: 0; + z-index: 10; + margin: 0; + padding: 0.25rem; + list-style: none; + background: var(--color-bg); + border: 0.0625rem solid var(--color-border); + border-radius: 0.375rem; + box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.12); + max-height: 16rem; + overflow-y: auto; +} + +.selectOption { + width: 100%; + display: flex; + flex-direction: column; + gap: 0.125rem; + padding: 0.5rem 0.625rem; + background: transparent; + border: none; + border-radius: 0.25rem; + cursor: pointer; + text-align: left; + color: var(--color-text); + + &:hover { + background: var(--color-hover-bg); + } +} + +.selectOptionActive { + background: var(--color-hover-bg); +} + +.optionPrimary { + font-size: 0.8125rem; + color: var(--color-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.optionSecondary { + font-size: 0.6875rem; + font-family: monospace; + color: var(--color-text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/sdk-app/src/DemoSettingsPanel.tsx b/sdk-app/src/DemoSettingsPanel.tsx index de4623d28..51fd0702a 100644 --- a/sdk-app/src/DemoSettingsPanel.tsx +++ b/sdk-app/src/DemoSettingsPanel.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef, useCallback } from 'react' import type { EntityIds } from './useEntities' import type { TokenStatus } from './useDemoManager' import styles from './DemoSettingsPanel.module.scss' @@ -17,15 +17,194 @@ interface DemoSettingsPanelProps { onRefreshToken: () => Promise } -const ENTITY_FIELDS: { key: keyof EntityIds; label: string }[] = [ - { key: 'companyId', label: 'Company ID' }, - { key: 'employeeId', label: 'Employee ID' }, - { key: 'contractorId', label: 'Contractor ID' }, - { key: 'payrollId', label: 'Payroll ID' }, +const TEXT_FIELDS: { key: keyof EntityIds; label: string }[] = [ { key: 'requestId', label: 'Request ID' }, { key: 'formId', label: 'Form ID' }, ] +interface EntityOption { + value: string + primary: string + secondary: string +} + +interface EntityLists { + employees: EntityOption[] + contractors: EntityOption[] + payrolls: EntityOption[] +} + +interface RawEmployee { + uuid?: string + first_name?: string | null + last_name?: string | null +} + +interface RawContractor { + uuid?: string + type?: string + first_name?: string | null + last_name?: string | null + business_name?: string | null +} + +interface RawPayroll { + uuid?: string + payroll_uuid?: string + check_date?: string + pay_period?: { start_date?: string; end_date?: string } +} + +function formatPayPeriod(payroll: RawPayroll): string { + const start = payroll.pay_period?.start_date + const end = payroll.pay_period?.end_date + if (start && end) return `${start} – ${end}` + if (payroll.check_date) return `Check date ${payroll.check_date}` + return 'Payroll' +} + +function formatContractor(contractor: RawContractor): string { + if (contractor.type === 'Business') { + return contractor.business_name || 'Business contractor' + } + const name = [contractor.first_name, contractor.last_name].filter(Boolean).join(' ').trim() + return name || 'Contractor' +} + +function formatEmployee(employee: RawEmployee): string { + const name = [employee.first_name, employee.last_name].filter(Boolean).join(' ').trim() + return name || 'Employee' +} + +async function fetchList(path: string): Promise { + try { + const res = await fetch(path) + if (!res.ok) return [] + const data = (await res.json()) as unknown + return Array.isArray(data) ? (data as T[]) : [] + } catch { + return [] + } +} + +interface EntitySelectProps { + label: string + value: string + options: EntityOption[] + isLoading: boolean + placeholder: string + fallbackPlaceholder: string + useFallback: boolean + onChange: (value: string) => void +} + +function EntitySelect({ + label, + value, + options, + isLoading, + placeholder, + fallbackPlaceholder, + useFallback, + onChange, +}: EntitySelectProps) { + const [isOpen, setIsOpen] = useState(false) + const wrapperRef = useRef(null) + + useEffect(() => { + if (!isOpen) return + const handleClick = (e: MouseEvent) => { + if (!wrapperRef.current?.contains(e.target as Node)) { + setIsOpen(false) + } + } + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setIsOpen(false) + } + document.addEventListener('mousedown', handleClick) + document.addEventListener('keydown', handleKey) + return () => { + document.removeEventListener('mousedown', handleClick) + document.removeEventListener('keydown', handleKey) + } + }, [isOpen]) + + if (useFallback) { + return ( +
+ + { + onChange(e.target.value) + }} + placeholder={fallbackPlaceholder} + /> +
+ ) + } + + const selected = options.find(option => option.value === value) + const hasOptions = options.length > 0 + + return ( +
+ +
+ + + {isOpen && hasOptions && ( +
    + {options.map(option => { + const isSelected = option.value === value + return ( +
  • + +
  • + ) + })} +
+ )} +
+
+ ) +} + export function DemoSettingsPanel({ isOpen, onClose, @@ -42,6 +221,13 @@ export function DemoSettingsPanel({ const currentDemoType = import.meta.env.VITE_DEMO_TYPE || 'react_sdk_demo_company_onboarded' const [selectedDemoType, setSelectedDemoType] = useState(currentDemoType) const confirmedSnapshot = useRef(entities) + const [lists, setLists] = useState({ + employees: [], + contractors: [], + payrolls: [], + }) + const [listsLoading, setListsLoading] = useState(false) + const [copyStatus, setCopyStatus] = useState<'idle' | 'copied'>('idle') useEffect(() => { if (!isOpen) { @@ -49,17 +235,84 @@ export function DemoSettingsPanel({ } }, [isOpen, entities]) - const hasChanges = ENTITY_FIELDS.some( - ({ key }) => entities[key] !== confirmedSnapshot.current[key], - ) - const env = typeof __SDK_APP_ENV__ !== 'undefined' ? __SDK_APP_ENV__ : 'demo' const build = typeof __SDK_APP_BUILD__ !== 'undefined' ? __SDK_APP_BUILD__ : 'dev' const mode = typeof __SDK_APP_PROXY_MODE__ !== 'undefined' ? __SDK_APP_PROXY_MODE__ : proxyMode + const isFlowTokenMode = mode === 'flow-token' + const companyId = entities.companyId + + useEffect(() => { + if (!isOpen || !isFlowTokenMode || !companyId) return + + let cancelled = false + const load = async () => { + setListsLoading(true) + const base = `/api/v1/companies/${companyId}` + const [employees, contractors, payrolls] = await Promise.all([ + fetchList(`${base}/employees`), + fetchList(`${base}/contractors`), + fetchList(`${base}/payrolls`), + ]) + + if (cancelled) return + + setLists({ + employees: employees + .filter(e => !!e.uuid) + .map(e => ({ + value: e.uuid as string, + primary: formatEmployee(e), + secondary: e.uuid as string, + })), + contractors: contractors + .filter(c => !!c.uuid) + .map(c => ({ + value: c.uuid as string, + primary: formatContractor(c), + secondary: c.uuid as string, + })), + payrolls: payrolls + .filter(p => !!(p.payroll_uuid || p.uuid)) + .map(p => { + const id = (p.payroll_uuid || p.uuid) as string + return { + value: id, + primary: formatPayPeriod(p), + secondary: id, + } + }), + }) + setListsLoading(false) + } + + void load() + return () => { + cancelled = true + } + }, [isOpen, isFlowTokenMode, companyId]) + + const hasChanges = + entities.employeeId !== confirmedSnapshot.current.employeeId || + entities.contractorId !== confirmedSnapshot.current.contractorId || + entities.payrollId !== confirmedSnapshot.current.payrollId || + TEXT_FIELDS.some(({ key }) => entities[key] !== confirmedSnapshot.current[key]) + + const handleCopyCompanyId = useCallback(async () => { + if (!companyId) return + try { + await navigator.clipboard.writeText(companyId) + setCopyStatus('copied') + setTimeout(() => { + setCopyStatus('idle') + }, 1500) + } catch { + // Clipboard unavailable + } + }, [companyId]) + if (!isOpen) return null - const isFlowTokenMode = mode === 'flow-token' const displayEnv = env === 'localzp' ? 'local' : env return ( @@ -146,7 +399,68 @@ export function DemoSettingsPanel({

Entity IDs

- {ENTITY_FIELDS.map(({ key, label }) => ( + +
+ +
+ + +
+
+ + { + onUpdateEntity('employeeId', value) + }} + /> + + { + onUpdateEntity('contractorId', value) + }} + /> + + { + onUpdateEntity('payrollId', value) + }} + /> + + {TEXT_FIELDS.map(({ key, label }) => (
Date: Wed, 6 May 2026 11:15:40 -0400 Subject: [PATCH 2/3] fix(sdk-app): correct entity combobox open/close behavior Clicking the input now toggles the menu, clicking outside reliably closes it, and the entity helper line stays in the DOM so opening the menu does not shift content below. Co-Authored-By: Claude Opus 4.7 (1M context) --- sdk-app/src/App.tsx | 3 + sdk-app/src/DemoSettingsPanel.module.scss | 69 ++-- sdk-app/src/DemoSettingsPanel.tsx | 455 +++++++++++----------- sdk-app/src/entityFormatters.ts | 47 +++ sdk-app/src/useEntityCatalog.ts | 99 +++++ 5 files changed, 404 insertions(+), 269 deletions(-) create mode 100644 sdk-app/src/entityFormatters.ts create mode 100644 sdk-app/src/useEntityCatalog.ts diff --git a/sdk-app/src/App.tsx b/sdk-app/src/App.tsx index 477eb20fe..d046398b7 100644 --- a/sdk-app/src/App.tsx +++ b/sdk-app/src/App.tsx @@ -6,6 +6,7 @@ import { Sidebar } from './Sidebar' import { DemoSettingsPanel } from './DemoSettingsPanel' import { TokenExpiredOverlay } from './TokenExpiredOverlay' import { useEntities } from './useEntities' +import { useEntityCatalog } from './useEntityCatalog' import { useDemoManager } from './useDemoManager' import { useAppMode } from './useAppMode' import { useThemeMode } from './useThemeMode' @@ -16,6 +17,7 @@ export function App() { const [sidebarOpen, setSidebarOpen] = useState(true) const [searchQuery, setSearchQuery] = useState('') const { entities, updateEntity, replaceEntities, resetToDefaults } = useEntities() + const entityCatalog = useEntityCatalog(entities.companyId) const demoManager = useDemoManager() const mode = useAppMode() const themeMode = useThemeMode() @@ -77,6 +79,7 @@ export function App() { proxyMode={demoManager.proxyMode} onCreateNewDemo={handleCreateNewDemo} onRefreshToken={demoManager.refreshToken} + entityCatalog={entityCatalog} /> {demoManager.tokenStatus === 'expired' && ( input, + > .selectWrapper { flex: 1; min-width: 0; } @@ -187,49 +188,51 @@ position: relative; } -.selectButton { - width: 100%; +.comboboxInputWrapper { + position: relative; display: flex; - align-items: center; - justify-content: space-between; - gap: 0.5rem; - padding: 0.5rem 0.625rem; - border: 0.0625rem solid var(--color-border); - border-radius: 0.375rem; - background: var(--color-bg); - color: var(--color-text); - font-size: 0.8125rem; - cursor: pointer; - text-align: left; - - &:focus { - outline: none; - border-color: var(--color-active); - box-shadow: 0 0 0 0.125rem var(--color-focus-ring); - } + align-items: stretch; - &:disabled { - opacity: 0.6; - cursor: not-allowed; + input { + flex: 1; + padding-right: 2rem; } } -.selectButtonContent { +.comboboxChevron { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 2rem; display: flex; - flex-direction: column; - min-width: 0; - flex: 1; + align-items: center; + justify-content: center; + background: transparent; + border: none; + cursor: pointer; + color: var(--color-text-muted); + font-size: 0.75rem; + padding: 0; } -.selectPlaceholder { +.helperLine { + margin-top: 0.25rem; + font-size: 0.6875rem; color: var(--color-text-muted); - flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.selectChevron { - color: var(--color-text-muted); +.helperLineHidden { + visibility: hidden; +} + +.selectStatus { + padding: 0.5rem 0.625rem; font-size: 0.75rem; - flex-shrink: 0; + color: var(--color-text-muted); } .selectMenu { diff --git a/sdk-app/src/DemoSettingsPanel.tsx b/sdk-app/src/DemoSettingsPanel.tsx index 51fd0702a..cfa5420e4 100644 --- a/sdk-app/src/DemoSettingsPanel.tsx +++ b/sdk-app/src/DemoSettingsPanel.tsx @@ -1,6 +1,8 @@ -import { useState, useEffect, useRef, useCallback } from 'react' +import { useState, useEffect, useRef, useCallback, useMemo, type ReactNode } from 'react' import type { EntityIds } from './useEntities' import type { TokenStatus } from './useDemoManager' +import type { EntityCatalog } from './useEntityCatalog' +import type { EntityOption } from './entityFormatters' import styles from './DemoSettingsPanel.module.scss' interface DemoSettingsPanelProps { @@ -15,6 +17,7 @@ interface DemoSettingsPanelProps { proxyMode: string onCreateNewDemo: (demoType: string) => Promise onRefreshToken: () => Promise + entityCatalog: EntityCatalog } const TEXT_FIELDS: { key: keyof EntityIds; label: string }[] = [ @@ -22,94 +25,85 @@ const TEXT_FIELDS: { key: keyof EntityIds; label: string }[] = [ { key: 'formId', label: 'Form ID' }, ] -interface EntityOption { +interface EntityComboboxProps { + label: string value: string - primary: string - secondary: string -} - -interface EntityLists { - employees: EntityOption[] - contractors: EntityOption[] - payrolls: EntityOption[] -} - -interface RawEmployee { - uuid?: string - first_name?: string | null - last_name?: string | null -} - -interface RawContractor { - uuid?: string - type?: string - first_name?: string | null - last_name?: string | null - business_name?: string | null + options: EntityOption[] + isLoading: boolean + placeholder: string + useFallback: boolean + onChange: (value: string) => void + trailing?: ReactNode } -interface RawPayroll { - uuid?: string - payroll_uuid?: string - check_date?: string - pay_period?: { start_date?: string; end_date?: string } +function filterOptions(options: EntityOption[], query: string): EntityOption[] { + const trimmed = query.trim().toLowerCase() + if (!trimmed) return options + return options.filter( + option => + option.primary.toLowerCase().includes(trimmed) || + option.secondary.toLowerCase().includes(trimmed), + ) } -function formatPayPeriod(payroll: RawPayroll): string { - const start = payroll.pay_period?.start_date - const end = payroll.pay_period?.end_date - if (start && end) return `${start} – ${end}` - if (payroll.check_date) return `Check date ${payroll.check_date}` - return 'Payroll' +interface CopyIdButtonProps { + value: string + ariaLabel: string } -function formatContractor(contractor: RawContractor): string { - if (contractor.type === 'Business') { - return contractor.business_name || 'Business contractor' - } - const name = [contractor.first_name, contractor.last_name].filter(Boolean).join(' ').trim() - return name || 'Contractor' -} +function CopyIdButton({ value, ariaLabel }: CopyIdButtonProps) { + const [status, setStatus] = useState<'idle' | 'copied'>('idle') + const timeoutRef = useRef | null>(null) -function formatEmployee(employee: RawEmployee): string { - const name = [employee.first_name, employee.last_name].filter(Boolean).join(' ').trim() - return name || 'Employee' -} + useEffect( + () => () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current) + }, + [], + ) -async function fetchList(path: string): Promise { - try { - const res = await fetch(path) - if (!res.ok) return [] - const data = (await res.json()) as unknown - return Array.isArray(data) ? (data as T[]) : [] - } catch { - return [] - } -} + const handleClick = useCallback(async () => { + if (!value) return + try { + await navigator.clipboard.writeText(value) + setStatus('copied') + if (timeoutRef.current) clearTimeout(timeoutRef.current) + timeoutRef.current = setTimeout(() => { + setStatus('idle') + }, 1500) + } catch { + // Clipboard unavailable + } + }, [value]) -interface EntitySelectProps { - label: string - value: string - options: EntityOption[] - isLoading: boolean - placeholder: string - fallbackPlaceholder: string - useFallback: boolean - onChange: (value: string) => void + return ( + + ) } -function EntitySelect({ +function EntityCombobox({ label, value, options, isLoading, placeholder, - fallbackPlaceholder, useFallback, onChange, -}: EntitySelectProps) { + trailing, +}: EntityComboboxProps) { const [isOpen, setIsOpen] = useState(false) + const [query, setQuery] = useState('') const wrapperRef = useRef(null) + const inputRef = useRef(null) + const inputId = `entity-${label.toLowerCase().replace(/\s+/g, '-')}-input` useEffect(() => { if (!isOpen) return @@ -119,7 +113,10 @@ function EntitySelect({ } } const handleKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') setIsOpen(false) + if (e.key === 'Escape') { + setIsOpen(false) + inputRef.current?.blur() + } } document.addEventListener('mousedown', handleClick) document.addEventListener('keydown', handleKey) @@ -129,78 +126,134 @@ function EntitySelect({ } }, [isOpen]) + const matched = useMemo(() => options.find(option => option.value === value), [options, value]) + const filtered = useMemo(() => filterOptions(options, query), [options, query]) + if (useFallback) { return (
- - { - onChange(e.target.value) - }} - placeholder={fallbackPlaceholder} - /> + +
+ { + onChange(e.target.value) + }} + placeholder={placeholder} + /> + {trailing} +
) } - const selected = options.find(option => option.value === value) - const hasOptions = options.length > 0 + const handleInputChange = (next: string) => { + setQuery(next) + onChange(next) + if (!isOpen) setIsOpen(true) + } + + const handleSelect = (option: EntityOption) => { + onChange(option.value) + setQuery('') + setIsOpen(false) + inputRef.current?.blur() + } return (
- -
- - - {isOpen && hasOptions && ( -
    - {options.map(option => { - const isSelected = option.value === value - return ( -
  • - + +
    +
    +
    + { + setQuery('') + }} + onMouseDown={() => { + setIsOpen(open => !open) + }} + onChange={e => { + handleInputChange(e.target.value) + }} + role="combobox" + aria-expanded={isOpen} + aria-controls={`${inputId}-listbox`} + aria-autocomplete="list" + autoComplete="off" + spellCheck={false} + data-1p-ignore="true" + data-lpignore="true" + /> + +
    + + {isOpen && ( +
      + {isLoading && options.length === 0 ? ( +
    • Loading…
    • + ) : filtered.length === 0 ? ( +
    • + {options.length === 0 ? 'No options available' : 'No matches'}
    • - ) - })} -
    - )} + ) : ( + filtered.map(option => { + const isSelected = option.value === value + return ( +
  • + +
  • + ) + }) + )} +
+ )} +
+ {trailing}
+ {matched && ( +
+ {matched.primary} +
+ )}
) } @@ -217,17 +270,11 @@ export function DemoSettingsPanel({ proxyMode, onCreateNewDemo, onRefreshToken, + entityCatalog, }: DemoSettingsPanelProps) { const currentDemoType = import.meta.env.VITE_DEMO_TYPE || 'react_sdk_demo_company_onboarded' const [selectedDemoType, setSelectedDemoType] = useState(currentDemoType) const confirmedSnapshot = useRef(entities) - const [lists, setLists] = useState({ - employees: [], - contractors: [], - payrolls: [], - }) - const [listsLoading, setListsLoading] = useState(false) - const [copyStatus, setCopyStatus] = useState<'idle' | 'copied'>('idle') useEffect(() => { if (!isOpen) { @@ -240,57 +287,6 @@ export function DemoSettingsPanel({ const mode = typeof __SDK_APP_PROXY_MODE__ !== 'undefined' ? __SDK_APP_PROXY_MODE__ : proxyMode const isFlowTokenMode = mode === 'flow-token' - const companyId = entities.companyId - - useEffect(() => { - if (!isOpen || !isFlowTokenMode || !companyId) return - - let cancelled = false - const load = async () => { - setListsLoading(true) - const base = `/api/v1/companies/${companyId}` - const [employees, contractors, payrolls] = await Promise.all([ - fetchList(`${base}/employees`), - fetchList(`${base}/contractors`), - fetchList(`${base}/payrolls`), - ]) - - if (cancelled) return - - setLists({ - employees: employees - .filter(e => !!e.uuid) - .map(e => ({ - value: e.uuid as string, - primary: formatEmployee(e), - secondary: e.uuid as string, - })), - contractors: contractors - .filter(c => !!c.uuid) - .map(c => ({ - value: c.uuid as string, - primary: formatContractor(c), - secondary: c.uuid as string, - })), - payrolls: payrolls - .filter(p => !!(p.payroll_uuid || p.uuid)) - .map(p => { - const id = (p.payroll_uuid || p.uuid) as string - return { - value: id, - primary: formatPayPeriod(p), - secondary: id, - } - }), - }) - setListsLoading(false) - } - - void load() - return () => { - cancelled = true - } - }, [isOpen, isFlowTokenMode, companyId]) const hasChanges = entities.employeeId !== confirmedSnapshot.current.employeeId || @@ -298,19 +294,6 @@ export function DemoSettingsPanel({ entities.payrollId !== confirmedSnapshot.current.payrollId || TEXT_FIELDS.some(({ key }) => entities[key] !== confirmedSnapshot.current[key]) - const handleCopyCompanyId = useCallback(async () => { - if (!companyId) return - try { - await navigator.clipboard.writeText(companyId) - setCopyStatus('copied') - setTimeout(() => { - setCopyStatus('idle') - }, 1500) - } catch { - // Clipboard unavailable - } - }, [companyId]) - if (!isOpen) return null const displayEnv = env === 'localzp' ? 'local' : env @@ -410,69 +393,69 @@ export function DemoSettingsPanel({ readOnly className={styles.readOnly} /> - +
- { onUpdateEntity('employeeId', value) }} + trailing={} /> - { onUpdateEntity('contractorId', value) }} + trailing={} /> - { onUpdateEntity('payrollId', value) }} + trailing={} /> - {TEXT_FIELDS.map(({ key, label }) => ( -
- - { - onUpdateEntity(key, e.target.value) - }} - placeholder={`Enter ${label.toLowerCase()}...`} - /> -
- ))} + {TEXT_FIELDS.map(({ key, label }) => { + const inputId = `entity-${key}-input` + return ( +
+ +
+ { + onUpdateEntity(key, e.target.value) + }} + placeholder={`Enter ${label.toLowerCase()}...`} + /> + +
+
+ ) + })}
{hasChanges && ( diff --git a/sdk-app/src/entityFormatters.ts b/sdk-app/src/entityFormatters.ts new file mode 100644 index 000000000..db4b9c014 --- /dev/null +++ b/sdk-app/src/entityFormatters.ts @@ -0,0 +1,47 @@ +export interface RawEmployee { + uuid?: string + first_name?: string | null + last_name?: string | null +} + +export interface RawContractor { + uuid?: string + type?: string + first_name?: string | null + last_name?: string | null + business_name?: string | null +} + +export interface RawPayroll { + uuid?: string + payroll_uuid?: string + check_date?: string + pay_period?: { start_date?: string; end_date?: string } +} + +export interface EntityOption { + value: string + primary: string + secondary: string +} + +export function formatPayPeriod(payroll: RawPayroll): string { + const start = payroll.pay_period?.start_date + const end = payroll.pay_period?.end_date + if (start && end) return `${start} – ${end}` + if (payroll.check_date) return `Check date ${payroll.check_date}` + return 'Payroll' +} + +export function formatContractor(contractor: RawContractor): string { + if (contractor.type === 'Business') { + return contractor.business_name || 'Business contractor' + } + const name = [contractor.first_name, contractor.last_name].filter(Boolean).join(' ').trim() + return name || 'Contractor' +} + +export function formatEmployee(employee: RawEmployee): string { + const name = [employee.first_name, employee.last_name].filter(Boolean).join(' ').trim() + return name || 'Employee' +} diff --git a/sdk-app/src/useEntityCatalog.ts b/sdk-app/src/useEntityCatalog.ts new file mode 100644 index 000000000..22eb03c5a --- /dev/null +++ b/sdk-app/src/useEntityCatalog.ts @@ -0,0 +1,99 @@ +import { useEffect, useState } from 'react' +import { + type EntityOption, + type RawContractor, + type RawEmployee, + type RawPayroll, + formatContractor, + formatEmployee, + formatPayPeriod, +} from './entityFormatters' + +export interface EntityCatalog { + employees: EntityOption[] + contractors: EntityOption[] + payrolls: EntityOption[] + isLoading: boolean +} + +const EMPTY_CATALOG: EntityCatalog = { + employees: [], + contractors: [], + payrolls: [], + isLoading: false, +} + +async function fetchList(path: string, signal: AbortSignal): Promise { + try { + const res = await fetch(path, { signal }) + if (!res.ok) return [] + const data = (await res.json()) as unknown + return Array.isArray(data) ? (data as T[]) : [] + } catch { + return [] + } +} + +export function useEntityCatalog(companyId: string): EntityCatalog { + const [catalog, setCatalog] = useState(EMPTY_CATALOG) + + const proxyMode = typeof __SDK_APP_PROXY_MODE__ !== 'undefined' ? __SDK_APP_PROXY_MODE__ : 'none' + const isFlowTokenMode = proxyMode === 'flow-token' + + useEffect(() => { + if (!isFlowTokenMode || !companyId) { + setCatalog(EMPTY_CATALOG) + return + } + + const controller = new AbortController() + setCatalog(prev => ({ ...prev, isLoading: true })) + + const load = async () => { + const base = `/api/v1/companies/${companyId}` + const [employees, contractors, payrolls] = await Promise.all([ + fetchList(`${base}/employees`, controller.signal), + fetchList(`${base}/contractors`, controller.signal), + fetchList(`${base}/payrolls`, controller.signal), + ]) + + if (controller.signal.aborted) return + + setCatalog({ + employees: employees + .filter(e => !!e.uuid) + .map(e => ({ + value: e.uuid as string, + primary: formatEmployee(e), + secondary: e.uuid as string, + })), + contractors: contractors + .filter(c => !!c.uuid) + .map(c => ({ + value: c.uuid as string, + primary: formatContractor(c), + secondary: c.uuid as string, + })), + payrolls: payrolls + .filter(p => !!(p.payroll_uuid || p.uuid)) + .map(p => { + const id = (p.payroll_uuid || p.uuid) as string + return { + value: id, + primary: formatPayPeriod(p), + secondary: id, + } + }), + isLoading: false, + }) + } + + void load() + + return () => { + controller.abort() + } + }, [companyId, isFlowTokenMode]) + + return catalog +} From 907191839e437f81e5687b9099de2312f93acbf7 Mon Sep 17 00:00:00 2001 From: Aaron Lee Date: Wed, 6 May 2026 11:24:57 -0400 Subject: [PATCH 3/3] feat(sdk-app): add keyboard navigation to entity combobox Arrow keys move through the menu, Enter selects the highlighted item, Home/End jump to the first/last option, and the active option scrolls into view and is announced via aria-activedescendant. Co-Authored-By: Claude Opus 4.7 (1M context) --- sdk-app/src/DemoSettingsPanel.tsx | 85 +++++++++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 4 deletions(-) diff --git a/sdk-app/src/DemoSettingsPanel.tsx b/sdk-app/src/DemoSettingsPanel.tsx index cfa5420e4..8a6303a0b 100644 --- a/sdk-app/src/DemoSettingsPanel.tsx +++ b/sdk-app/src/DemoSettingsPanel.tsx @@ -1,4 +1,12 @@ -import { useState, useEffect, useRef, useCallback, useMemo, type ReactNode } from 'react' +import { + useState, + useEffect, + useRef, + useCallback, + useMemo, + type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, +} from 'react' import type { EntityIds } from './useEntities' import type { TokenStatus } from './useDemoManager' import type { EntityCatalog } from './useEntityCatalog' @@ -101,9 +109,12 @@ function EntityCombobox({ }: EntityComboboxProps) { const [isOpen, setIsOpen] = useState(false) const [query, setQuery] = useState('') + const [activeIndex, setActiveIndex] = useState(0) const wrapperRef = useRef(null) const inputRef = useRef(null) + const optionRefs = useRef>([]) const inputId = `entity-${label.toLowerCase().replace(/\s+/g, '-')}-input` + const optionId = (index: number) => `${inputId}-option-${index}` useEffect(() => { if (!isOpen) return @@ -129,6 +140,24 @@ function EntityCombobox({ const matched = useMemo(() => options.find(option => option.value === value), [options, value]) const filtered = useMemo(() => filterOptions(options, query), [options, query]) + useEffect(() => { + if (!isOpen) return + const selectedIdx = filtered.findIndex(option => option.value === value) + setActiveIndex(selectedIdx >= 0 ? selectedIdx : 0) + }, [isOpen]) // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + setActiveIndex(idx => { + if (filtered.length === 0) return 0 + return Math.min(Math.max(idx, 0), filtered.length - 1) + }) + }, [filtered.length]) + + useEffect(() => { + if (!isOpen) return + optionRefs.current[activeIndex]?.scrollIntoView({ block: 'nearest' }) + }, [activeIndex, isOpen]) + if (useFallback) { return (
@@ -152,6 +181,7 @@ function EntityCombobox({ const handleInputChange = (next: string) => { setQuery(next) onChange(next) + setActiveIndex(0) if (!isOpen) setIsOpen(true) } @@ -162,6 +192,37 @@ function EntityCombobox({ inputRef.current?.blur() } + const handleKeyDown = (e: ReactKeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + if (!isOpen) { + setIsOpen(true) + return + } + if (filtered.length === 0) return + setActiveIndex(idx => (idx + 1) % filtered.length) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + if (!isOpen) { + setIsOpen(true) + return + } + if (filtered.length === 0) return + setActiveIndex(idx => (idx - 1 + filtered.length) % filtered.length) + } else if (e.key === 'Enter') { + if (isOpen && filtered[activeIndex]) { + e.preventDefault() + handleSelect(filtered[activeIndex]) + } + } else if (e.key === 'Home' && isOpen) { + e.preventDefault() + setActiveIndex(0) + } else if (e.key === 'End' && isOpen) { + e.preventDefault() + setActiveIndex(Math.max(filtered.length - 1, 0)) + } + } + return (
@@ -183,9 +244,13 @@ function EntityCombobox({ onChange={e => { handleInputChange(e.target.value) }} + onKeyDown={handleKeyDown} role="combobox" aria-expanded={isOpen} aria-controls={`${inputId}-listbox`} + aria-activedescendant={ + isOpen && filtered[activeIndex] ? optionId(activeIndex) : undefined + } aria-autocomplete="list" autoComplete="off" spellCheck={false} @@ -220,16 +285,28 @@ function EntityCombobox({ {options.length === 0 ? 'No options available' : 'No matches'} ) : ( - filtered.map(option => { + filtered.map((option, index) => { const isSelected = option.value === value + const isActive = index === activeIndex return ( -
  • +
  • { + optionRefs.current[index] = el + }} + role="option" + aria-selected={isSelected} + >