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. |
| onOpenChange | (open: boolean) => void | - | Modal only: open-state callback, required in controlled mode. |
| 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, and 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. That is intentional, so a mis-click does not destroy typed text. CallapiRef.reset()to clear.