IssueReporter
issue-reporterGitHub issue draft builder that renders a template to Markdown and builds a prefill link.
Usage
Basic usage
Three built-in templates: bug, new component, and enhancement. The preview below shows the assembled Markdown live.
Fill in the fields and the issue body shows up here.
<IssueReporter
repo="hulianui/hulian"
components={[
{ slug: "select", name: "Select" },
{ slug: "combobox", name: "Combobox" },
]}
onSubmit={(draft) => console.log(draft)}
/>Modal variant
Wrapped in ModalForm: the submit button comes from the modal footer, and failed validation keeps the modal open.
<IssueReporterModal
trigger={<Button>Report an issue</Button>}
components={components}
onSubmit={(draft) => console.log(draft)}
/>Rendered preview
preview="rendered" shows the rendered Markdown instead of the source.
What you need
Writing an issue by hand drops fields too easily
Desired API
<IssueReporter /><IssueReporter preview="rendered" defaultType="feature" />Custom template
Swap templates for your own set; toMarkdown decides what the body looks like.
Page:const docsTemplate = {
type: "docs",
label: "Docs fix",
labels: ["documentation"],
fields: [
{ name: "page", label: "Page URL", control: "input", required: true },
{ name: "problem", label: "What is wrong" },
],
toMarkdown: (values) => `Page: ${values.page}`,
};
<IssueReporter templates={[docsTemplate]} />Too-long fallback
Lower the limit to 300 characters to simulate an oversized link: the Open on GitHub button disappears, leaving Copy Markdown plus a notice.
## What went wrong
This is a very long body. This is a very long body. This is a very long body. This is a very long body. This is a very long body. This is a very long body. This is a very long body. This is a very long body. This is a very long body. This is a very long body. This is a very long body. This is a very long body. This is a very long body. This is a very long body. This is a very long body. This is a very long body. This is a very long body. This is a very long body. This is a very long body. This is a very long body.<IssueReporter urlLimit={300} />When to use
Use it to turn "this component is missing / this is broken / this could be better" into a complete issue draft that can be pasted straight into GitHub: a feedback entry point for a component library, a "report a problem" button in an internal platform, or a "this page is wrong" link in docs.
It is not a generic form: the field list comes from an issue template, not an arbitrary schema. Use ProForm for general data entry, ModalForm for create/edit dialogs, and MarkdownEditor when all you need is one Markdown input.
Import
import { BUILTIN_ISSUE_TEMPLATES, GITHUB_URL_MAX_LENGTH, IssueReporter, IssueReporterModal, buildIssueUrl, createIssueDraft, isUrlTooLong, issueSection, normalizeRepo, renderIssueMarkdown } from "@hulianui/ui"Props
IssueReporterProps. IssueReporterModalProps extends it and adds the modal-only rows at the end.
| Name | Type | Default | Description |
|---|---|---|---|
| repo | string | "hulianui/hulian" | Target repository as owner/name; a full GitHub URL or a .git suffix also works and is normalized by normalizeRepo. |
| templates | IssueTemplate[] | BUILTIN_ISSUE_TEMPLATES | Template set ({ type, label, labels?, tone?, fields, toMarkdown }), replaceable as a whole. |
| type | string | — | Controlled current template type. |
| defaultType | string | templates[0].type | Uncontrolled initial template type. |
| components | IssueComponentOption[] | — | Related-component candidates ({ slug, name? }); the field is not rendered without it. The component never fetches llms.txt or the registry — you supply the list. |
| relatedComponent | string | — | Controlled related-component value (slug). |
| defaultRelatedComponent | string | "" | Uncontrolled initial related component. |
| defaultTitle | string | "" | Initial title. Named this way so it never collides with the HTML title attribute. |
| defaultValues | IssueFieldValues | — | Initial template field values, keyed by field name. |
| showSubmit | boolean | true | Render the built-in submit button; IssueReporterModal always sets it to false. |
| openInNewTab | boolean | true | Whether "Open on GitHub" calls window.open. |
| preview | "source" | "rendered" | false | "source" | Preview mode: CodeBlock source, rendered Markdown, or off. |
| urlLimit | number | 8000 | Prefill link length limit; above it the component degrades (see Pitfalls). |
| text | Partial\<IssueReporterText\> | — | UI copy overrides; omit it and the reporter takes its copy from the ConfigProvider locale. Not the same thing as a template's labels (GitHub labels). |
| actions | ReactNode | — | Extra buttons appended to the action row. |
| apiRef | MutableRefObject\<IssueReporterApi | null\> | — | Imperative handle: submit() / getDraft() / getUrl() / reset(). |
| className | string | — | Class name on the form body. |
| open / defaultOpen | boolean | — | Modal only: controlled / uncontrolled open state. |
| trigger | ReactElement | — | Modal only: element that opens the dialog. |
| modalTitle | string | From locale | Modal only: dialog title. Omit it and the modal follows the ConfigProvider locale. |
| submitText / cancelText | string | — | Modal only: footer button labels. |
| modalClassName | string | — | Modal only: dialog container class name (width and so on). |
Events
| Event | Type | Description |
|---|---|---|
| onSubmit | (draft: IssueDraft) => void | Fires after validation passes with the structured draft ({ type, title, relatedComponent?, labels, values, body }). The component stops here; sending it anywhere is your call. |
| onDraftChange | (draft: IssueDraft) => void | Fires on every input change with the latest draft, including the rendered body. |
| onTypeChange | (type: string) => void | Template type changed. |
| onRelatedComponentChange | (slug: string) => void | Related component changed; picking "none" reports an empty string. |
| onOpenUrl | (url: string) => void | Fires after the prefill link is built, whether or not a tab is opened. |
| onCopy | (markdown: string) => void | Fires after copying; the copied text is the Markdown body, not the URL. |
Slots
| Slot | Type | Description |
|---|---|---|
| actions | ReactNode | Extra buttons on the right of the action row. |
| trigger | ReactElement | Modal only: element that opens the dialog. |
Accessibility
- Every field sits in a Field, so Base UI wires
htmlFor/id, links the error text througharia-describedby, and setsaria-invalidautomatically. - Required errors appear only after a submit attempt, so the form never greets a first-time user with a wall of red.
- The too-long fallback explains itself through an Alert instead of silently disabling a button.
- The copy button swaps its label to "Copied" for 1.5 seconds; the change is textual, not icon-only, so screen readers announce it.
- Keyboard behavior for the two dropdowns comes from Select and Combobox.
Pitfalls
- It does not create the issue, and it must not receive a token. The component only hands back a draft and builds a prefill link. That link opens GitHub's own "new issue" form; the issue exists only after the user confirms it there. If you want one-click submission, take the
draftto your own server and call the GitHub API from there — never ship a PAT to the browser. - Prefill links have a length ceiling. In practice a URL beyond roughly 8000 characters (
GITHUB_URL_MAX_LENGTH) gets truncated by browsers, proxies, or mail clients, or is rejected outright. Past that the component does not render the "Open on GitHub" button and shows a notice plus "Copy Markdown" instead. The check measures the whole URL, not the body: percent-encoded CJK costs nine characters per glyph, so a long title alone can exhaust the budget. - The related-component list must come from outside. The component never fetches
llms.txtor the registry — the data source and its caching belong to the consumer, the same boundary ComponentPicker draws. Omitcomponentsand the field disappears. - `control: "markdown"` pulls in tiptap. MarkdownEditor depends on
@tiptap/*, which is not small. All three built-in templates deliberately usetextarea; opt a field intomarkdownyourself when you need rich text. - Do not wrap it in a `<form>` expecting native submit. The body deliberately renders no
<form>element, because it has to nest inside ModalForm's form and nested forms are invalid HTML. Outside a dialog, use the built-in submit button orapiRef.submit(). - A template's `labels` and the component's `text` are different things. The former are GitHub labels (they land in
draft.labelsand the URL'slabelsparameter); the latter overrides UI copy. - UI copy follows the locale by default. Without
text, every string comes from the ConfigProvider locale and falls back to the built-in Chinese when no provider is present;modalTitleonIssueReporterModalworks the same way. Priority is thetextprop, then the locale, then the fallback. Note that the field `label` and `placeholder` of a template are not covered by the locale — they live in thetemplatesdata, so an English surface needs its own templates too. - Switching templates keeps values of same-named fields. The value map is shared across templates, so if both
bugandfeaturedeclaresummary, its content survives the switch — intentional, so a mis-click does not destroy typed text. CallapiRef.reset()to clear.