diff --git a/doc/manual/en/09-articles.md b/doc/manual/en/09-articles.md index c22cf7de..77a9a07f 100644 --- a/doc/manual/en/09-articles.md +++ b/doc/manual/en/09-articles.md @@ -74,6 +74,17 @@ Articles support richer content than a plain note. They can include structured t Ticket messages can reference articles using the `$` mention syntax in the message editor. In the rendered message, the mention is displayed as the article title. Each reference is recorded on the ticket and shown in a dedicated Articles section, sorted by title. +## Keyboard shortcuts + +Like all paginated list views in the application, the articles list supports keyboard shortcuts for quick navigation. You can use `Alt+1` through `Alt+0` to open items in the currently visible list. + +On the article form page, the following shortcuts are available to jump directly to specific fields: +* `Alt+1`: Jump to Title +* `Alt+2`: Jump to Tags +* `Alt+3`: Focus the Editor + +These shortcuts work universally. See the **Navigation** chapter for more details. + ## Why it matters The articles feature turns day-to-day support experience into reusable organizational knowledge. Over time, that helps billetsys serve not only as a ticket system, but also as a knowledge platform for support teams and customers. diff --git a/doc/manual/en/11-tickets.md b/doc/manual/en/11-tickets.md index 1075b599..561d5a63 100644 --- a/doc/manual/en/11-tickets.md +++ b/doc/manual/en/11-tickets.md @@ -88,7 +88,20 @@ For roles that use the ticket queues, these keyboard shortcuts are available fro * `Ctrl+C` opens **Closed tickets** * `Ctrl++` opens **Create ticket** -When a ticket queue view is open, `Ctrl+1` through `Ctrl+9` open the first through ninth ticket in the currently visible list order, and `Ctrl+0` opens the tenth ticket. +When a ticket queue view is open, `Alt+1` through `Alt+9` open the first through ninth ticket in the currently visible list order, and `Alt+0` opens the tenth ticket. + +On the ticket detail page, the following shortcuts are available to jump directly to specific fields or sections (and will automatically open dropdown menus): + +* `Alt+1`: Jump to Summary / Title +* `Alt+2`: Jump to Category +* `Alt+3`: Jump to Status +* `Alt+4`: Jump to External Issue link +* `Alt+5`: Jump to Affects Version +* `Alt+6`: Jump to Resolved Version +* `Alt+7`: Jump to the Messages Thread +* `Alt+8`: Focus the Reply Editor + +These shortcuts work universally, even if you are viewing read-only versions of the fields. If you are actively typing inside a text box (like the Reply Editor) and wish to use a shortcut to jump elsewhere, simply use the `Alt+Number` shortcut directly—they will gracefully navigate you out of the current field. ## Ticket search diff --git a/doc/manual/en/15-companies.md b/doc/manual/en/15-companies.md index 3d3afb71..e3c369e7 100644 --- a/doc/manual/en/15-companies.md +++ b/doc/manual/en/15-companies.md @@ -56,6 +56,20 @@ From an operational perspective, companies make it easier to answer questions su That makes the company record one of the key administrative foundations of the system. +## Keyboard shortcuts + +Like all paginated list views in the application, the companies list supports keyboard shortcuts for quick navigation. You can use `Alt+1` through `Alt+0` to open items in the currently visible list. + +On the company form page, the following shortcuts are available to jump directly to specific fields (and open dropdowns automatically): +* `Alt+1`: Jump to Name +* `Alt+2`: Jump to Phone number +* `Alt+3`: Jump to Address 1 +* `Alt+4`: Jump to City +* `Alt+5`: Jump to Country +* `Alt+6`: Jump to Time zone + +These shortcuts work universally. See the **Navigation** chapter for more details. + ## Role perspective Companies are most important for the roles that coordinate or administer broader sets of work, such as admins, superusers, TAMs, and support staff. diff --git a/doc/manual/en/16-users.md b/doc/manual/en/16-users.md index e0f29736..fc1bc77e 100644 --- a/doc/manual/en/16-users.md +++ b/doc/manual/en/16-users.md @@ -65,6 +65,19 @@ This is essential because the quality of the user structure affects almost every * Reporting scope * Company coordination +## Keyboard shortcuts + +Like all paginated list views in the application, the users list supports keyboard shortcuts for quick navigation. You can use `Alt+1` through `Alt+0` to open items in the currently visible list. + +On the user form page, the following shortcuts are available to jump directly to specific fields (and open dropdowns automatically): +* `Alt+1`: Jump to Username +* `Alt+2`: Jump to Name +* `Alt+3`: Jump to Email + +Only `Alt+1` through `Alt+3` are currently assigned on the user form page. + +These shortcuts work universally. See the **Navigation** chapter for more details. + ## Why it matters Without a strong user model, billetsys would only be a ticket container. With structured user accounts and role assignment, it becomes a coordinated support platform where responsibilities, visibility, and communication all follow the same design. diff --git a/doc/manual/en/17-categories.md b/doc/manual/en/17-categories.md index 639c2b2b..7b5775e1 100644 --- a/doc/manual/en/17-categories.md +++ b/doc/manual/en/17-categories.md @@ -48,6 +48,16 @@ That context can help administrators keep the classification system understandab Categories can include attachments. This makes it possible to keep supporting examples, templates, or reference files directly on the category definition. +## Keyboard shortcuts + +Like all paginated list views in the application, the categories list supports keyboard shortcuts for quick navigation. You can use `Alt+1` through `Alt+0` to open items in the currently visible list. + +On the category form page, the following shortcuts are available to jump directly to specific fields (and open dropdowns automatically): +* `Alt+1`: Jump to Name +* `Alt+2`: Jump to Default + +These shortcuts work universally. See the **Navigation** chapter for more details. + ## Role perspective Categories are mostly maintained by administrative roles, but they affect many other parts of the application. Users, support staff, TAMs, and superusers all benefit from clearer ticket classification even if they are not the ones managing the category definitions directly. diff --git a/doc/manual/en/18-entitlements.md b/doc/manual/en/18-entitlements.md index a0eed4a4..294b84d0 100644 --- a/doc/manual/en/18-entitlements.md +++ b/doc/manual/en/18-entitlements.md @@ -52,6 +52,16 @@ Over time, entitlements help teams answer questions such as: This makes entitlements one of the key concepts behind consistent service-aware ticket handling. +## Keyboard shortcuts + +Like all paginated list views in the application, the entitlements list supports keyboard shortcuts for quick navigation. You can use `Alt+1` through `Alt+0` to open items in the currently visible list. + +On the entitlement form page, the following shortcuts are available to jump directly to specific fields: +* `Alt+1`: Jump to Name +* `Alt+2`: Jump to Description + +These shortcuts work universally. See the **Navigation** chapter for more details. + ## Role perspective Entitlements are mainly configured and maintained by administrative roles, but their effects are visible throughout the application. Support roles, superusers, TAMs, and ticket creators all benefit from having a clear service context behind the cases they work with. diff --git a/doc/manual/en/19-levels.md b/doc/manual/en/19-levels.md index 931762b0..9e285714 100644 --- a/doc/manual/en/19-levels.md +++ b/doc/manual/en/19-levels.md @@ -50,3 +50,17 @@ Over time, levels help teams answer questions such as: * How service expectations differ across customers This makes the levels feature an important part of making support handling more structured and transparent. + +## Keyboard shortcuts + +Like all paginated list views in the application, the levels list supports keyboard shortcuts for quick navigation. You can use `Alt+1` through `Alt+0` to open items in the currently visible list. + +On the level form page, the following shortcuts are available to jump directly to specific fields (and open dropdowns automatically): +* `Alt+1`: Jump to Name +* `Alt+2`: Jump to Level +* `Alt+3`: Jump to Color +* `Alt+4`: Jump to Country +* `Alt+5`: Jump to From day +* `Alt+6`: Jump to To day + +These shortcuts work universally. See the **Navigation** chapter for more details. diff --git a/doc/manual/en/24-navigation.md b/doc/manual/en/24-navigation.md index 4616b0d0..73240202 100644 --- a/doc/manual/en/24-navigation.md +++ b/doc/manual/en/24-navigation.md @@ -51,9 +51,11 @@ For example, navigation helps users: ## Keyboard shortcuts and pagination -Keyboard shortcuts such as `Ctrl+1` through `Ctrl+0` always refer to items **on the current page**. When a list spans multiple pages, moving to a different page updates the shortcut targets to match the visible items. +Keyboard shortcuts such as `Alt+1` through `Alt+0` always refer to items **on the current page** or **the current view**. On paginated lists, moving to a different page updates the shortcut targets to match the visible items. -This means that `Ctrl+1` on page 2 opens the first ticket on page 2 — not the first ticket across the entire dataset. This behavior keeps shortcuts predictable regardless of how many items exist. +This means that `Alt+1` on page 2 opens the first item on page 2 — not the first item across the entire dataset. This behavior keeps shortcuts predictable regardless of how many items exist. On detail pages, these same shortcuts are context-aware and jump focus between major sections and form fields. + +These shortcuts work universally across the application, even if you are currently typing inside a text box or have a dropdown menu open. Using a shortcut will gracefully navigate you out of your current input and directly into the new target field or open the targeted dropdown. If you wish to manually unfocus any active input or dropdown without navigating away, you can press `Escape`. ## Why it matters diff --git a/src/frontend/src/components/editor/LexicalEditor.tsx b/src/frontend/src/components/editor/LexicalEditor.tsx index ace61f2d..665438d4 100644 --- a/src/frontend/src/components/editor/LexicalEditor.tsx +++ b/src/frontend/src/components/editor/LexicalEditor.tsx @@ -8,7 +8,6 @@ import { CodeHighlightNode, CodeNode } from "@lexical/code"; import { - AutoFocusExtension, ClearEditorExtension, DecoratorTextExtension, HorizontalRuleExtension, @@ -185,6 +184,7 @@ interface LexicalEditorProps { required?: boolean; ticketSuggestApiBase?: string; excludeTicketId?: number | string; + "data-shortcut-index"?: string | number; } const SUPERSCRIPT: TextMatchTransformer = { @@ -337,6 +337,7 @@ export default function LexicalEditor({ required, ticketSuggestApiBase, excludeTicketId, + "data-shortcut-index": dataShortcutIndex, }: LexicalEditorProps) { const [floatingAnchorElem, setFloatingAnchorElem] = useState(null); @@ -353,7 +354,6 @@ export default function LexicalEditor({ defineExtension({ dependencies: [ RichTextExtension, - AutoFocusExtension, SelectionAlwaysOnDisplayExtension, HistoryExtension, configExtension(LinkExtension, { @@ -401,7 +401,19 @@ export default function LexicalEditor({ ); return ( -
+
{ + if (event.target === event.currentTarget) { + const editable = event.currentTarget.querySelector( + '[contenteditable="true"]', + ); + editable?.focus(); + } + }} + >
diff --git a/src/frontend/src/components/layout/AuthenticatedHeader.tsx b/src/frontend/src/components/layout/AuthenticatedHeader.tsx index 398833ba..3043a931 100644 --- a/src/frontend/src/components/layout/AuthenticatedHeader.tsx +++ b/src/frontend/src/components/layout/AuthenticatedHeader.tsx @@ -14,6 +14,7 @@ import { useDarkModeToggle } from "../../hooks/useDarkModeToggle"; import { installationCompanyName } from "../../utils/installationBranding"; import { formatInstallationClock, toQueryString } from "../../utils/formatting"; import { SmartLink, normalizeClientPath } from "../../utils/routing"; +import { isEditableTarget } from "../../utils/keyboard"; import { ticketCountsApiPath, ticketLabelForRole, @@ -64,20 +65,6 @@ interface AuthenticatedHeaderProps { session: Session | null; } -function isEditableTarget(target: EventTarget | null): boolean { - if (!(target instanceof HTMLElement)) { - return false; - } - const tagName = target.tagName.toLowerCase(); - return ( - target.isContentEditable || - tagName === "input" || - tagName === "textarea" || - tagName === "select" || - Boolean(target.closest("[contenteditable='true']")) - ); -} - export default function AuthenticatedHeader({ session, }: AuthenticatedHeaderProps) { diff --git a/src/frontend/src/components/ui/aevr/country-dropdown.tsx b/src/frontend/src/components/ui/aevr/country-dropdown.tsx index 15c66f1b..0ce02eba 100644 --- a/src/frontend/src/components/ui/aevr/country-dropdown.tsx +++ b/src/frontend/src/components/ui/aevr/country-dropdown.tsx @@ -55,6 +55,7 @@ interface CountryDropdownProps { disabled?: boolean; placeholder?: string; slim?: boolean; + "data-shortcut-index"?: string | number; } const CountryDropdownComponent = ( diff --git a/src/frontend/src/hooks/useNumberShortcuts.ts b/src/frontend/src/hooks/useNumberShortcuts.ts new file mode 100644 index 00000000..55e47f9f --- /dev/null +++ b/src/frontend/src/hooks/useNumberShortcuts.ts @@ -0,0 +1,111 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; + +interface UseNumberShortcutsProps { + items?: T[]; + getPath?: (item: T) => string | undefined | null; + enableFieldJumps?: boolean; +} + +export default function useNumberShortcuts({ + items, + getPath, + enableFieldJumps, +}: UseNumberShortcutsProps = {}) { + const navigate = useNavigate(); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) { + return; + } + + // Allow Escape key to blur any active input + if (event.key === "Escape") { + if (document.activeElement instanceof HTMLElement) { + event.preventDefault(); + document.activeElement.blur(); + } + return; + } + + if (!event.altKey || event.ctrlKey || event.metaKey) { + return; + } + + const keyString = event.code + ? event.code.replace("Digit", "").replace("Numpad", "") + : event.key; + const shortcutIndex = + keyString === "0" ? 10 : Number.parseInt(keyString, 10); + + if ( + Number.isNaN(shortcutIndex) || + shortcutIndex < 1 || + shortcutIndex > 10 + ) { + return; + } + + let handled = false; + + // 1. Try list items + if (items && getPath) { + const item = items[shortcutIndex - 1]; + if (item) { + const path = getPath(item); + if (path) { + event.preventDefault(); + navigate(path); + handled = true; + } + } + } + + // 2. Try field jumps if enabled and not already handled + if (!handled && enableFieldJumps) { + const selector = `[data-shortcut-index="${shortcutIndex}"]`; + const target = document.querySelector(selector); + if (target) { + event.preventDefault(); + target.focus({ preventScroll: true }); + target.scrollIntoView({ behavior: "smooth", block: "center" }); + + // If the target is a dropdown trigger, open it automatically + if ( + target.getAttribute("role") === "combobox" || + target.getAttribute("data-slot") === "select-trigger" || + target.tagName.toLowerCase() === "button" + ) { + setTimeout(() => { + target.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowDown", + code: "ArrowDown", + bubbles: true, + cancelable: true, + }), + ); + target.click(); + }, 10); + } + + handled = true; + } + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [items, getPath, enableFieldJumps, navigate]); +} diff --git a/src/frontend/src/pages/ArticleFormPage.tsx b/src/frontend/src/pages/ArticleFormPage.tsx index a4d32567..b8668c00 100644 --- a/src/frontend/src/pages/ArticleFormPage.tsx +++ b/src/frontend/src/pages/ArticleFormPage.tsx @@ -12,6 +12,7 @@ import { useNavigate, useParams } from "react-router-dom"; import { toast } from "sonner"; import useJson from "../hooks/useJson"; import useSubmissionGuard from "../hooks/useSubmissionGuard"; +import useNumberShortcuts from "../hooks/useNumberShortcuts"; import DataState from "../components/common/DataState"; import PageHeader from "../components/layout/PageHeader"; import LexicalEditor from "../components/editor/LexicalEditor"; @@ -50,6 +51,8 @@ export default function ArticleFormPage({ mode }: ArticleFormPageProps) { const submissionGuard = useSubmissionGuard(); const isEdit = mode === "edit"; + useNumberShortcuts({ enableFieldJumps: true }); + const updateFormState = (field: keyof ArticleFormState, value: string) => { if (saveState.error) { setSaveState((current) => ({ ...current, error: "" })); @@ -139,6 +142,7 @@ export default function ArticleFormPage({ mode }: ArticleFormPageProps) { } className="bg-background shadow-sm" required + data-shortcut-index="1" />

Article titles must be unique. @@ -154,6 +158,7 @@ export default function ArticleFormPage({ mode }: ArticleFormPageProps) { updateFormState("tags", event.target.value) } className="bg-background shadow-sm" + data-shortcut-index="2" /> @@ -167,6 +172,7 @@ export default function ArticleFormPage({ mode }: ArticleFormPageProps) { inputRef={bodyInputRef} rows={16} required + data-shortcut-index="3" />

diff --git a/src/frontend/src/pages/ArticlesPage.tsx b/src/frontend/src/pages/ArticlesPage.tsx index 17f0c201..2ba1cef0 100644 --- a/src/frontend/src/pages/ArticlesPage.tsx +++ b/src/frontend/src/pages/ArticlesPage.tsx @@ -11,6 +11,7 @@ import PaginationControls from "../components/common/PaginationControls"; import SortableTableHead from "../components/common/SortableTableHead"; import PageHeader from "../components/layout/PageHeader"; import usePaginatedList from "../hooks/usePaginatedList"; +import useNumberShortcuts from "../hooks/useNumberShortcuts"; import { SmartLink } from "../utils/routing"; import type { SessionPageProps } from "../types/app"; import type { ArticleRecord, CollectionResponse } from "../types/domain"; @@ -48,6 +49,11 @@ export default function ArticlesPage({ sessionState }: SessionPageProps) { sortKeys: SORT_KEYS, }); + useNumberShortcuts({ + items: (articlesState.data?.items || []).slice(0, 10), + getPath: (article) => `/articles/${article.id}`, + }); + return (
`/categories/${category.id}`, + }); + return (
( field: K, value: CategoryFormState[K], @@ -179,6 +182,7 @@ export default function CategoryFormPage({ mode }: CategoryFormPageProps) { updateFormState("name", event.target.value) } required + data-shortcut-index="1" /> @@ -189,7 +193,7 @@ export default function CategoryFormPage({ mode }: CategoryFormPageProps) { updateFormState("isDefault", value === "true") } > - + @@ -207,6 +211,7 @@ export default function CategoryFormPage({ mode }: CategoryFormPageProps) { } inputRef={descriptionInputRef} rows={10} + data-shortcut-index="3" />
diff --git a/src/frontend/src/pages/CompaniesPage.tsx b/src/frontend/src/pages/CompaniesPage.tsx index db049a08..b7ee539a 100644 --- a/src/frontend/src/pages/CompaniesPage.tsx +++ b/src/frontend/src/pages/CompaniesPage.tsx @@ -13,6 +13,7 @@ import DataState from "../components/common/DataState"; import PaginationControls from "../components/common/PaginationControls"; import SortDropdown from "../components/common/SortDropdown"; import PageHeader from "../components/layout/PageHeader"; +import useNumberShortcuts from "../hooks/useNumberShortcuts"; import { SmartLink } from "../utils/routing"; import type { SessionPageProps } from "../types/app"; import type { CollectionResponse, CompanyRecord } from "../types/domain"; @@ -56,6 +57,11 @@ export default function CompaniesPage({ sessionState }: SessionPageProps) { defaultSort: "name", }); + useNumberShortcuts({ + items: pageItems.slice(0, 10), + getPath: (company) => `/companies/${company.id}`, + }); + return (
{ if (!company) { return; @@ -333,6 +336,7 @@ export default function CompanyFormPage({ mode }: CompanyFormPageProps) { updateFormState("name", event.target.value) } required + data-shortcut-index="1" /> @@ -345,6 +349,7 @@ export default function CompanyFormPage({ mode }: CompanyFormPageProps) { onChange={(value) => updateFormState("phoneNumber", value || "") } + data-shortcut-index="2" /> @@ -356,6 +361,7 @@ export default function CompanyFormPage({ mode }: CompanyFormPageProps) { onChange={(event) => updateFormState("address1", event.target.value) } + data-shortcut-index="3" /> @@ -378,6 +384,7 @@ export default function CompanyFormPage({ mode }: CompanyFormPageProps) { onChange={(event) => updateFormState("city", event.target.value) } + data-shortcut-index="4" /> @@ -438,6 +445,7 @@ export default function CompanyFormPage({ mode }: CompanyFormPageProps) { : current, ); }} + data-shortcut-index="5" /> @@ -450,7 +458,7 @@ export default function CompanyFormPage({ mode }: CompanyFormPageProps) { updateFormState("timezoneId", value) } > - + diff --git a/src/frontend/src/pages/DirectoryUserFormPage.tsx b/src/frontend/src/pages/DirectoryUserFormPage.tsx index 80df8a3f..7a645d7c 100644 --- a/src/frontend/src/pages/DirectoryUserFormPage.tsx +++ b/src/frontend/src/pages/DirectoryUserFormPage.tsx @@ -15,6 +15,7 @@ import DataState from "../components/common/DataState"; import PageHeader from "../components/layout/PageHeader"; import useJson from "../hooks/useJson"; import useSubmissionGuard from "../hooks/useSubmissionGuard"; +import useNumberShortcuts from "../hooks/useNumberShortcuts"; import { postForm } from "../utils/api"; import { createDirectoryUserFormState } from "../utils/forms"; import { toQueryString } from "../utils/formatting"; @@ -104,6 +105,8 @@ export default function DirectoryUserFormPage({ )?.name, )?.alpha2 as ComponentProps["defaultCountry"]) || "US"; + useNumberShortcuts({ enableFieldJumps: true }); + useEffect(() => { if (!bootstrap) { return; @@ -318,6 +321,7 @@ export default function DirectoryUserFormPage({ } readOnly={isEdit} required + data-shortcut-index="1" /> @@ -329,6 +333,7 @@ export default function DirectoryUserFormPage({ onChange={(event) => updateFormState("fullName", event.target.value) } + data-shortcut-index="2" /> @@ -342,6 +347,7 @@ export default function DirectoryUserFormPage({ updateFormState("email", event.target.value) } required + data-shortcut-index="3" /> diff --git a/src/frontend/src/pages/DirectoryUsersPage.tsx b/src/frontend/src/pages/DirectoryUsersPage.tsx index 016509be..dffd9185 100644 --- a/src/frontend/src/pages/DirectoryUsersPage.tsx +++ b/src/frontend/src/pages/DirectoryUsersPage.tsx @@ -11,6 +11,7 @@ import DataState from "../components/common/DataState"; import PaginationControls from "../components/common/PaginationControls"; import SortDropdown from "../components/common/SortDropdown"; import PageHeader from "../components/layout/PageHeader"; +import useNumberShortcuts from "../hooks/useNumberShortcuts"; import useJson from "../hooks/useJson"; import useClientPagination from "../hooks/useClientPagination"; import { toQueryString } from "../utils/formatting"; @@ -112,6 +113,11 @@ export default function DirectoryUsersPage({ ); }; + useNumberShortcuts({ + items: pageItems.slice(0, 10), + getPath: (user) => user.detailPath, + }); + return (
( field: K, value: EntitlementFormState[K], @@ -283,6 +286,7 @@ export default function EntitlementFormPage({ updateFormState("name", event.target.value) } required + data-shortcut-index="1" /> @@ -294,6 +298,7 @@ export default function EntitlementFormPage({ onChange={(value) => updateFormState("description", value)} rows={10} required + data-shortcut-index="2" /> diff --git a/src/frontend/src/pages/EntitlementsListPage.tsx b/src/frontend/src/pages/EntitlementsListPage.tsx index 15be8781..c90f0559 100644 --- a/src/frontend/src/pages/EntitlementsListPage.tsx +++ b/src/frontend/src/pages/EntitlementsListPage.tsx @@ -10,6 +10,7 @@ import { Link } from "react-router-dom"; import useJson from "../hooks/useJson"; import DataState from "../components/common/DataState"; import PageHeader from "../components/layout/PageHeader"; +import useNumberShortcuts from "../hooks/useNumberShortcuts"; import { SmartLink } from "../utils/routing"; import type { SessionPageProps } from "../types/app"; import type { CollectionResponse, EntitlementRecord } from "../types/domain"; @@ -38,6 +39,11 @@ export default function EntitlementsListPage(props: SessionPageProps) { const entitlementsState = useJson>("/api/entitlements"); + useNumberShortcuts({ + items: (entitlementsState.data?.items || []).slice(0, 10), + getPath: (entitlement) => `/entitlements/${entitlement.id}`, + }); + return (
`/entitlements/${entitlement.id}`, + }); + return (
{ if (!level) { return; @@ -232,6 +235,7 @@ export default function LevelFormPage({ mode }: LevelFormPageProps) { updateFormState("name", event.target.value) } required + data-shortcut-index="1" /> @@ -246,6 +250,7 @@ export default function LevelFormPage({ mode }: LevelFormPageProps) { updateFormState("level", event.target.value) } required + data-shortcut-index="2" /> @@ -256,7 +261,7 @@ export default function LevelFormPage({ mode }: LevelFormPageProps) { value={formState.color || undefined} onValueChange={(value) => updateFormState("color", value)} > - + @@ -309,6 +314,7 @@ export default function LevelFormPage({ mode }: LevelFormPageProps) { : current, ); }} + data-shortcut-index="4" /> @@ -321,7 +327,7 @@ export default function LevelFormPage({ mode }: LevelFormPageProps) { updateFormState("fromDay", value) } > - + @@ -344,7 +350,7 @@ export default function LevelFormPage({ mode }: LevelFormPageProps) { value={formState.toDay || undefined} onValueChange={(value) => updateFormState("toDay", value)} > - + diff --git a/src/frontend/src/pages/LevelsPage.tsx b/src/frontend/src/pages/LevelsPage.tsx index c51ab0af..6055744b 100644 --- a/src/frontend/src/pages/LevelsPage.tsx +++ b/src/frontend/src/pages/LevelsPage.tsx @@ -15,6 +15,7 @@ import PageHeader from "../components/layout/PageHeader"; import useJson from "../hooks/useJson"; import useClientPagination from "../hooks/useClientPagination"; import { SmartLink } from "../utils/routing"; +import useNumberShortcuts from "../hooks/useNumberShortcuts"; import type { SessionPageProps } from "../types/app"; import type { CollectionResponse, LevelRecord } from "../types/domain"; import { Card, CardHeader } from "../components/ui/card"; @@ -64,6 +65,11 @@ export default function LevelsPage({ sessionState }: SessionPageProps) { defaultSort: "name", }); + useNumberShortcuts({ + items: pageItems.slice(0, 10), + getPath: (level) => `/levels/${level.id}`, + }); + return (
(null); const submissionGuard = useSubmissionGuard(); const showFixedCompany = sessionState.data?.role === "superuser"; + useNumberShortcuts({ enableFieldJumps: true }); useEffect(() => { if (!bootstrap) { return; @@ -225,6 +227,7 @@ export default function SupportTicketCreatePage({ updateFormState("title", event.target.value) } required + data-shortcut-index="1" /> @@ -303,7 +306,10 @@ export default function SupportTicketCreatePage({ }} required > - + @@ -331,7 +337,10 @@ export default function SupportTicketCreatePage({ updateFormState("categoryId", value) } > - + @@ -356,7 +365,10 @@ export default function SupportTicketCreatePage({ updateFormState("affectsVersionId", value) } > - + @@ -387,6 +399,7 @@ export default function SupportTicketCreatePage({ rows={10} required ticketSuggestApiBase={ticketSuggestApiBase} + data-shortcut-index="6" /> - + diff --git a/src/frontend/src/pages/SupportTicketDetailPage.tsx b/src/frontend/src/pages/SupportTicketDetailPage.tsx index cc9ff722..d4040369 100644 --- a/src/frontend/src/pages/SupportTicketDetailPage.tsx +++ b/src/frontend/src/pages/SupportTicketDetailPage.tsx @@ -21,6 +21,7 @@ import { } from "../components/users/UserComponents"; import useJson from "../hooks/useJson"; import useSubmissionGuard from "../hooks/useSubmissionGuard"; +import useNumberShortcuts from "../hooks/useNumberShortcuts"; import { postForm, postMultipart } from "../utils/api"; import { formatFileSize, @@ -320,6 +321,9 @@ export default function SupportTicketDetailPage({ const canEditResolvedVersion = ticket?.editableResolvedVersion ?? true; const showLevelField = apiBase !== "/api/user/tickets" || sessionState.data?.role === "tam"; + + useNumberShortcuts({ enableFieldJumps: true }); + const ticketHeading = ticket?.name || ticket?.title || titleFallback; const showSummaryField = Boolean(ticket?.title || formState?.title) && @@ -618,6 +622,7 @@ export default function SupportTicketDetailPage({ ) : ( updateFormState("title", event.target.value) } + data-shortcut-index="1" required /> )} @@ -633,7 +639,11 @@ export default function SupportTicketDetailPage({ Category {isClosed || !canEditCategory ? ( - + ) : ( + ) : ( + ) ) : (
@@ -736,6 +751,7 @@ export default function SupportTicketDetailPage({ event.target.value, ) } + data-shortcut-index="4" className="pr-14" /> {formState.externalIssueLink ? ( @@ -765,6 +781,7 @@ export default function SupportTicketDetailPage({ ) || "-" } readOnly + data-shortcut-index="5" /> ) : ( - + @@ -1024,8 +1042,10 @@ export default function SupportTicketDetailPage({

Messages

@@ -1070,6 +1090,7 @@ export default function SupportTicketDetailPage({ required ticketSuggestApiBase={apiBase} excludeTicketId={ticket.id} + data-shortcut-index="8" />
diff --git a/src/frontend/src/pages/SupportTicketsPage.tsx b/src/frontend/src/pages/SupportTicketsPage.tsx index 9dff8166..7d084d8a 100644 --- a/src/frontend/src/pages/SupportTicketsPage.tsx +++ b/src/frontend/src/pages/SupportTicketsPage.tsx @@ -6,18 +6,19 @@ * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. */ -import { useEffect, useMemo } from "react"; +import { useMemo } from "react"; import DataState from "../components/common/DataState"; import PaginationControls from "../components/common/PaginationControls"; import SortableTableHead from "../components/common/SortableTableHead"; import PageHeader from "../components/layout/PageHeader"; import usePaginatedList from "../hooks/usePaginatedList"; import { shouldUseLightTextOnColor } from "../utils/formatting"; +import useNumberShortcuts from "../hooks/useNumberShortcuts"; import { SmartLink } from "../utils/routing"; import type { SessionPageProps } from "../types/app"; import type { CollectionResponse, TicketListItem } from "../types/domain"; import { Button } from "../components/ui/button"; -import { useLocation, useNavigate } from "react-router-dom"; +import { useLocation } from "react-router-dom"; import { Table, TableBody, @@ -51,20 +52,6 @@ const SORT_KEYS = [ "affects", ] as const; -function isEditableTarget(target: EventTarget | null): boolean { - if (!(target instanceof HTMLElement)) { - return false; - } - const tagName = target.tagName.toLowerCase(); - return ( - target.isContentEditable || - tagName === "input" || - tagName === "textarea" || - tagName === "select" || - Boolean(target.closest("[contenteditable='true']")) - ); -} - export default function SupportTicketsPage({ title, view, @@ -73,7 +60,6 @@ export default function SupportTicketsPage({ createFallbackPath = "/support/tickets/new", }: SupportTicketsPageProps) { const location = useLocation(); - const navigate = useNavigate(); const locationSearchTerm = new URLSearchParams(location.search).get("q") || ""; @@ -127,42 +113,10 @@ export default function SupportTicketsPage({ const shortcutTickets = (ticketsState.data?.items || []).slice(0, 10); - useEffect(() => { - if (shortcutTickets.length === 0) { - return undefined; - } - const handleKeyDown = (event: KeyboardEvent) => { - if ( - event.defaultPrevented || - !event.ctrlKey || - event.metaKey || - event.altKey || - event.shiftKey || - isEditableTarget(event.target) - ) { - return; - } - const shortcutIndex = - event.key === "0" ? 10 : Number.parseInt(event.key, 10); - if ( - Number.isNaN(shortcutIndex) || - shortcutIndex < 1 || - shortcutIndex > 10 - ) { - return; - } - const ticket = shortcutTickets[shortcutIndex - 1]; - if (!ticket?.detailPath) { - return; - } - event.preventDefault(); - navigate(ticket.detailPath); - }; - document.addEventListener("keydown", handleKeyDown); - return () => { - document.removeEventListener("keydown", handleKeyDown); - }; - }, [navigate, shortcutTickets]); + useNumberShortcuts({ + items: shortcutTickets, + getPath: (ticket) => ticket?.detailPath, + }); return (
diff --git a/src/frontend/src/pages/TicketWorkbenchFormPage.tsx b/src/frontend/src/pages/TicketWorkbenchFormPage.tsx index bdcf773d..19d2a438 100644 --- a/src/frontend/src/pages/TicketWorkbenchFormPage.tsx +++ b/src/frontend/src/pages/TicketWorkbenchFormPage.tsx @@ -14,6 +14,7 @@ import DataState from "../components/common/DataState"; import PageHeader from "../components/layout/PageHeader"; import useJson from "../hooks/useJson"; import useSubmissionGuard from "../hooks/useSubmissionGuard"; +import useNumberShortcuts from "../hooks/useNumberShortcuts"; import { postForm } from "../utils/api"; import { toQueryString } from "../utils/formatting"; import { resolvePostRedirectPath } from "../utils/routing"; @@ -73,6 +74,8 @@ export default function TicketWorkbenchFormPage(props: SessionPageProps) { ); const bootstrap = bootstrapState.data; + useNumberShortcuts({ enableFieldJumps: true }); + useEffect(() => { if (!bootstrap) { return; @@ -261,6 +264,7 @@ export default function TicketWorkbenchFormPage(props: SessionPageProps) { updateFormState("title", event.target.value) } required + data-shortcut-index="1" /> @@ -273,7 +277,7 @@ export default function TicketWorkbenchFormPage(props: SessionPageProps) { updateFormState("status", value) } > - + @@ -305,7 +309,7 @@ export default function TicketWorkbenchFormPage(props: SessionPageProps) { ) } > - + @@ -330,7 +334,7 @@ export default function TicketWorkbenchFormPage(props: SessionPageProps) { updateFormState("companyEntitlementId", value) } > - + @@ -355,7 +359,7 @@ export default function TicketWorkbenchFormPage(props: SessionPageProps) { updateFormState("categoryId", value) } > - + @@ -380,7 +384,7 @@ export default function TicketWorkbenchFormPage(props: SessionPageProps) { updateFormState("affectsVersionId", value) } > - + @@ -405,7 +409,7 @@ export default function TicketWorkbenchFormPage(props: SessionPageProps) { updateFormState("resolvedVersionId", value) } > - + @@ -432,6 +436,7 @@ export default function TicketWorkbenchFormPage(props: SessionPageProps) { updateFormState("externalIssueLink", event.target.value) } placeholder="https://github.com/..." + data-shortcut-index="8" /> diff --git a/src/frontend/src/utils/keyboard.ts b/src/frontend/src/utils/keyboard.ts new file mode 100644 index 00000000..2f4d1921 --- /dev/null +++ b/src/frontend/src/utils/keyboard.ts @@ -0,0 +1,21 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +export function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false; + } + const tagName = target.tagName.toLowerCase(); + return ( + target.isContentEditable || + tagName === "input" || + tagName === "textarea" || + tagName === "select" || + Boolean(target.closest("[contenteditable='true']")) + ); +}