diff --git a/apps/web/content/docs/ui/auto-form.mdx b/apps/web/content/docs/ui/auto-form.mdx index e340e11d72..018ca19d44 100644 --- a/apps/web/content/docs/ui/auto-form.mdx +++ b/apps/web/content/docs/ui/auto-form.mdx @@ -79,7 +79,54 @@ Import each one from `@vitnode/core/components/form/fields/`. Spread | `AutoFormArray` | `array` | [Arrays](#arrays) | Every field takes `label`, `labelRight` (content pinned to the label's right -edge) and `description`. +edge), `description` and [`tip`](#field-tips). + +## Field tips + + + +`tip` adds a small question-mark button right after the label. It opens a +bubble with extra help that most people can skip. Every built-in field +supports it, `AutoFormArray` included. + +```tsx +{ + id: "slug", + component: props => ( + {t("public")}} + /> + ), +} +``` + +Pick the right slot for your text: + +| Prop | Shows | Use it for | +| ------------- | ------------------------------------------ | ----------------------------------------- | +| `label` | Always, above the control | The field's name | +| `description` | Always, below the control | What the field does, in one line | +| `tip` | Only when asked, in a bubble by the label | Background, edge cases, "why would I..." | +| `labelRight` | Always, at the end of the label row | Actions, badges, a language switcher | + +- **Mouse:** hover the icon for a moment, then move onto the bubble to read it. + Or click the icon: the bubble stays open until you click it again or + anywhere else. +- **Touch:** tap the icon to open, tap it again or anywhere else to close. +- **Keyboard:** Tab reaches the icon, Enter or Space opens it, Escape closes it. + +Pressing the icon never focuses the control, toggles a checkbox or switch, or +submits the form. When a submit fails, focus still lands on the invalid +control, not on the icon. Empty values (`undefined`, `""`, `false`) render no +icon, so `tip={isAdmin && t("slug.tip")}` is fine. + +Translate the tip like the label, with `use-intl`. In a +[multi-language field](#multi-language-fields) it stays in the UI language +while you switch the language you're editing. ## Schema @@ -324,7 +371,8 @@ import { A field is any component. Build it from AutoForm's parts and it gets the same ids, ARIA and errors as the built-in ones: -- `AutoFormLabel` and `AutoFormDesc` render the label and description. +- `AutoFormLabel` and `AutoFormDesc` render the label and description. Pass + `tip` to `AutoFormLabel` for the [help icon](#field-tips). - `FormControl` passes `id`, `aria-invalid` and `aria-describedby` to its child. - `FormMessage` shows the field's errors. @@ -343,12 +391,13 @@ import { FormControl, FormMessage } from '@vitnode/core/components/ui/form' const SlowModeField = ({ label, description, + tip, field, }: Omit & { field: FormFieldApi }) => ( <> - {label} + {label} & { field: FormFieldApi }) => ( <>
- {label} + {label} {field.value === 0 ? 'Off' : `${field.value}s`} @@ -59,6 +60,7 @@ export default function AutoFormCustomFieldExample() { {...props} description="Seconds a member waits between posts in this thread." label="Slow mode" + tip="Moderators skip the wait. Existing posts are not affected." /> ), }, diff --git a/apps/web/src/docs/examples/auto-form-tip.tsx b/apps/web/src/docs/examples/auto-form-tip.tsx new file mode 100644 index 0000000000..1e7196df03 --- /dev/null +++ b/apps/web/src/docs/examples/auto-form-tip.tsx @@ -0,0 +1,54 @@ +import { AutoForm } from '@vitnode/core/components/form/auto-form' +import { AutoFormInput } from '@vitnode/core/components/form/fields/input' +import { AutoFormSwitch } from '@vitnode/core/components/form/fields/switch' +import { Badge } from '@vitnode/core/components/ui/badge' +import { toast } from 'sonner' +import { z } from 'zod' + +export default function AutoFormTipExample() { + const formSchema = z.object({ + slug: z + .string() + .regex( + /^[a-z\d]+(?:-[a-z\d]+)*$/, + 'Use lowercase letters, numbers and dashes', + ) + .default('general-chat'), + slowMode: z.boolean().default(false), + }) + + return ( + ( + Public} + tip="Changing the slug later breaks every link already shared, so pick one you can live with. Old bookmarks will not follow along." + /> + ), + }, + { + id: 'slowMode', + component: (props) => ( + + ), + }, + ]} + formSchema={formSchema} + onSubmit={(values) => { + toast.success('Forum saved', { description: `/${values.slug}` }) + }} + /> + ) +} diff --git a/apps/web/src/locales/@vitnode/core/pl.json b/apps/web/src/locales/@vitnode/core/pl.json index 8a3ac2a9bc..c6057336cc 100644 --- a/apps/web/src/locales/@vitnode/core/pl.json +++ b/apps/web/src/locales/@vitnode/core/pl.json @@ -2709,6 +2709,7 @@ "account": "Konto" }, "optional": "Opcjonalne", + "field_tip": "Więcej informacji", "or": "lub", "pick_color": "Wybierz kolor", "color_value": "Wartość koloru", diff --git a/packages/vitnode/src/components/form/auto-form-tip.test.tsx b/packages/vitnode/src/components/form/auto-form-tip.test.tsx new file mode 100644 index 0000000000..0da2a91c6b --- /dev/null +++ b/packages/vitnode/src/components/form/auto-form-tip.test.tsx @@ -0,0 +1,247 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; + +import type { ItemAutoFormComponentProps } from "./auto-form"; + +import { LanguagesProvider } from "../languages-provider"; +import { AutoForm } from "./auto-form"; +import { AutoFormLabel } from "./common/label"; +import { AutoFormArray } from "./fields/array"; +import { AutoFormCheckbox } from "./fields/checkbox"; +import { AutoFormInput } from "./fields/input"; +import { multiLangValueSchema } from "./fields/multi-lang"; +import { AutoFormSwitch } from "./fields/switch"; + +const TIP_NAME = /core\.global\.field_tip/; + +const tipButtons = () => screen.queryAllByRole("button", { name: TIP_NAME }); + +const languages = [ + { code: "en", name: "English" }, + { code: "pl", name: "Polski" }, +]; + +describe("AutoForm field tips", () => { + beforeEach(() => { + // Reduced motion skips the shake, which needs the Web Animations API the + // test DOM does not have. + vi.stubGlobal("matchMedia", (query: string) => ({ + addEventListener: vi.fn(), + matches: true, + media: query, + removeEventListener: vi.fn(), + })); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it.each([ + { name: "no tip", tip: undefined }, + { name: "an empty tip", tip: "" }, + ])("renders no help button for $name", ({ tip }) => { + render( + ( + + ), + }, + ]} + formSchema={z.object({ title: z.string().default("") })} + />, + ); + + expect(tipButtons()).toHaveLength(0); + expect(screen.getByRole("textbox", { name: "Title" })).toBeDefined(); + }); + + it("opens the tip on click and closes it on Escape", async () => { + const { container } = render( + ( + + ), + }, + ]} + formSchema={z.object({ title: z.string().default("") })} + />, + ); + + const button = screen.getByRole("button", { + name: "core.global.field_tip Title", + }); + expect(screen.getByRole("textbox", { name: "Title" })).toBeDefined(); + expect(container.querySelector("[tip]")).toBeNull(); + + fireEvent.click(button); + const popup = await screen.findByRole("dialog"); + expect(popup.textContent).toBe("Shown in search results"); + + fireEvent.keyDown(popup, { key: "Escape" }); + await waitFor(() => { + expect(screen.queryByText("Shown in search results")).toBeNull(); + }); + }); + + it.each([ + { + name: "checkbox", + role: "checkbox", + component: AutoFormCheckbox, + }, + { + name: "switch", + role: "switch", + component: AutoFormSwitch, + }, + ])( + "clicking the help button of a $name neither submits nor toggles it", + async ({ component: Component, role }) => { + const onSubmit = vi.fn(); + render( + ( + + ), + }, + ]} + formSchema={z.object({ agree: z.boolean().default(false) })} + onSubmit={onSubmit} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: TIP_NAME })); + await screen.findByRole("dialog"); + + expect(onSubmit).not.toHaveBeenCalled(); + expect( + screen.getByRole(role, { name: "Agree" }).getAttribute("aria-checked"), + ).toBe("false"); + }, + ); + + // A custom control may not mark itself `aria-invalid`, so the form falls + // back to the field's first focusable element - which must not be the tip. + it.each([ + { + name: "a built-in input", + component: (props: ItemAutoFormComponentProps) => ( + + ), + }, + { + name: "a custom control", + component: ({ field }: ItemAutoFormComponentProps) => ( + <> + Title + field.onChange(event.target.value)} + value={String(field.value ?? "")} + /> + + ), + }, + ])( + "focuses $name, not its help button, after a failed submit", + async ({ component }) => { + const onSubmit = vi.fn(); + render( + , + ); + + fireEvent.click( + screen.getByRole("button", { name: "core.global.submit" }), + ); + + const input = screen.getByRole("textbox", { name: "Title" }); + await waitFor(() => { + expect(document.activeElement).toBe(input); + }); + expect(onSubmit).not.toHaveBeenCalled(); + }, + ); + + it("renders one help button for a multi-language field, not one per language", () => { + render( + + ( + + ), + }, + ]} + formSchema={z.object({ title: multiLangValueSchema().default([]) })} + /> + , + ); + + expect(tipButtons()).toHaveLength(1); + }); + + it("keeps the array's own tip and its row tips, and names the group by its label only", () => { + render( + ( + ( + + ), + }, + ]} + label="Links" + tip="Shown in the footer" + /> + ), + }, + ]} + formSchema={z.object({ + links: z.array(z.object({ url: z.string() })).default([{ url: "" }]), + })} + />, + ); + + expect(tipButtons()).toHaveLength(2); + expect(screen.getByRole("group", { name: "Links" }).tagName).toBe( + "FIELDSET", + ); + }); +}); diff --git a/packages/vitnode/src/components/form/auto-form.tsx b/packages/vitnode/src/components/form/auto-form.tsx index d80958b60a..207b80c488 100644 --- a/packages/vitnode/src/components/form/auto-form.tsx +++ b/packages/vitnode/src/components/form/auto-form.tsx @@ -37,6 +37,7 @@ import { TabsPanels, TabsTrigger, } from "../ui/tabs"; +import { AUTO_FORM_TIP_SLOT } from "./common/tip"; export type { AnyFormFieldApi, FormFieldApi } from "../ui/form"; export { setFormFieldError } from "../ui/form"; @@ -123,6 +124,13 @@ export interface ItemAutoFormComponentProps { pattern?: string; type?: string; }; + /** + * Extra help opened from a question-mark button beside the label - on + * hover, click, tap or keyboard. Unlike `description`, it is hidden until + * asked for. Not filled by `AutoForm`; pass it to the field component + * yourself, translated. Empty content renders no button. + */ + tip?: React.ReactNode; } type AutoFormValidator> = StandardSchemaV1< @@ -187,10 +195,20 @@ const outermostInvalidFields = (root: HTMLElement): HTMLElement[] => element => !element.parentElement?.closest(INVALID_FIELD_SELECTOR), ); -const focusTargetOf = (fieldElement: HTMLElement): HTMLElement | null => - fieldElement.querySelector( - `[aria-invalid=true]:is(${FOCUSABLE_SELECTOR})`, - ) ?? fieldElement.querySelector(FOCUSABLE_SELECTOR); +// Help buttons sit before the control, so they are never a focus target. +const focusTargetOf = (fieldElement: HTMLElement): HTMLElement | null => { + const candidates = [ + ...fieldElement.querySelectorAll(FOCUSABLE_SELECTOR), + ].filter(element => element.dataset.slot !== AUTO_FORM_TIP_SLOT); + + return ( + candidates.find( + element => element.getAttribute("aria-invalid") === "true", + ) ?? + candidates[0] ?? + null + ); +}; // Mirrors SHAKE_KEYFRAMES in lib/motion, run through WAAPI so the form does // not pull motion's animation engine into every bundle that renders it. diff --git a/packages/vitnode/src/components/form/common/label.tsx b/packages/vitnode/src/components/form/common/label.tsx index 83803e87b7..705cc7e083 100644 --- a/packages/vitnode/src/components/form/common/label.tsx +++ b/packages/vitnode/src/components/form/common/label.tsx @@ -4,42 +4,81 @@ import { useTranslations } from "use-intl"; import { FieldLabel } from "@/components/ui/field"; import { useFormField } from "@/components/ui/form"; +import { AutoFormTip, hasTipContent } from "./tip"; + /** * A form field's label, linked to the field's control by id. Render it inside * a form field, above the control, in a custom field component. + * + * With a `tip`, the label sits in a row with a help button right after it, + * then the "optional" hint and `labelRight`. The button is a sibling of the + * `label`, so pressing it never focuses or toggles the control. */ export const AutoFormLabel = ({ children, labelRight, className, isOptional, + tip, ...props }: React.ComponentProps & { /** Appends the translated "optional" hint after the label. */ isOptional?: boolean; /** Content pushed to the end of the label row, e.g. a language switcher. */ labelRight?: React.ReactNode; + /** Extra help opened from a question-mark button beside the label. Empty content renders no button. */ + tip?: React.ReactNode; }) => { const t = useTranslations("core.global"); const { formItemId } = useFormField(); + const labelId = props.id ?? `${formItemId}-label`; + + if (!hasTipContent(tip)) { + return ( + + {children} + {isOptional && ( + {t("optional")} + )} + {!!labelRight && {labelRight}} + + ); + } return ( - - {children} - {isOptional && ( - {t("optional")} - )} + {/* A long label wraps its own text, so the icon never drops to a line of its own. */} +
+ + {children} + {isOptional && {t("optional")}} + + {tip} + {isOptional && ( + + {t("optional")} + + )} +
{!!labelRight && {labelRight}} -
+
); }; diff --git a/packages/vitnode/src/components/form/common/tip.test.ts b/packages/vitnode/src/components/form/common/tip.test.ts new file mode 100644 index 0000000000..24535d0524 --- /dev/null +++ b/packages/vitnode/src/components/form/common/tip.test.ts @@ -0,0 +1,24 @@ +import { createElement } from "react"; +import { describe, expect, it } from "vitest"; + +import { hasTipContent } from "./tip"; + +describe("hasTipContent", () => { + it.each([ + { name: "undefined", tip: undefined, expected: false }, + { name: "null", tip: null, expected: false }, + { name: "false", tip: false, expected: false }, + { name: "true", tip: true, expected: false }, + { name: "an empty string", tip: "", expected: false }, + { name: "a blank string", tip: " ", expected: false }, + { name: "text", tip: "Shown in search results", expected: true }, + { name: "zero", tip: 0, expected: true }, + { + name: "an element", + tip: createElement("strong", null, "Heads up"), + expected: true, + }, + ])("$name -> $expected", ({ tip, expected }) => { + expect(hasTipContent(tip)).toBe(expected); + }); +}); diff --git a/packages/vitnode/src/components/form/common/tip.tsx b/packages/vitnode/src/components/form/common/tip.tsx new file mode 100644 index 0000000000..21d111febe --- /dev/null +++ b/packages/vitnode/src/components/form/common/tip.tsx @@ -0,0 +1,74 @@ +import { cn } from "cn"; +import { CircleQuestionMarkIcon } from "lucide-react"; +import { useId } from "react"; +import { useTranslations } from "use-intl"; + +import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover"; + +const TIP_OPEN_DELAY = 200; +const TIP_CLOSE_DELAY = 100; + +/** `data-slot` of the {@link AutoFormTip} button, so form focus logic can skip it. */ +export const AUTO_FORM_TIP_SLOT = "auto-form-tip"; + +/** Whether `tip` has something to show - `null`, `false` and blank strings do not. */ +export const hasTipContent = (tip: React.ReactNode): boolean => { + if (tip === null || tip === undefined || typeof tip === "boolean") { + return false; + } + + return typeof tip !== "string" || tip.trim() !== ""; +}; + +/** + * A small question-mark button that opens extra help about a form field in a + * popover - on hover (after 200ms), click, tap, or Enter/Space; Escape or a + * click outside closes it. Renders nothing when `children` is empty. + * + * Built-in `AutoForm` fields render it beside the label from their `tip` prop; + * use it directly only in a custom label. + */ +export const AutoFormTip = ({ + children, + className, + labelId, +}: { + /** The help content. Translate it in the UI language before passing it. */ + children?: React.ReactNode; + className?: string; + /** Id of the field's label, appended to the button's accessible name, e.g. "More information Title". */ + labelId?: string; +}) => { + const t = useTranslations("core.global"); + const id = useId(); + + if (!hasTipContent(children)) return null; + + return ( + + + + {t("field_tip")} + + + + {children} + + + ); +}; diff --git a/packages/vitnode/src/components/form/fields/array.tsx b/packages/vitnode/src/components/form/fields/array.tsx index 5e684d5597..8f493c49c3 100644 --- a/packages/vitnode/src/components/form/fields/array.tsx +++ b/packages/vitnode/src/components/form/fields/array.tsx @@ -25,6 +25,7 @@ import { useFormField, } from "../../ui/form"; import { AutoFormFieldSlot } from "../auto-form"; +import { AutoFormTip, hasTipContent } from "../common/tip"; const reconcileRowKeys = (previous: number[], length: number): number[] => { if (previous.length >= length) { @@ -82,8 +83,11 @@ export const AutoFormArray = ({ className, itemParams, otherProps, + tip, }: AutoFormArrayProps) => { const { form } = useFormApi(); + const legendId = React.useId(); + const withTip = !!label && hasTipContent(tip); const { errors } = useFormField(); const t = useTranslations("core.global"); const id = parentField.name; @@ -113,8 +117,20 @@ export const AutoFormArray = ({ const canAdd = !maxItems || length < maxItems; return ( -
- {!!label && {label}} +
+ {!!label && + (withTip ? ( + + {label} + {tip} + + ) : ( + {label} + ))} {!!description && {description}} diff --git a/packages/vitnode/src/components/form/fields/cascader.tsx b/packages/vitnode/src/components/form/fields/cascader.tsx index 7290d858a8..23b5805b28 100644 --- a/packages/vitnode/src/components/form/fields/cascader.tsx +++ b/packages/vitnode/src/components/form/fields/cascader.tsx @@ -18,6 +18,7 @@ export const AutoFormCascader = ({ label, labelRight, description, + tip, otherProps: { isOptional }, field, options, @@ -34,7 +35,7 @@ export const AutoFormCascader = ({ Omit) => ( <> {!!label && ( - + {label} )} diff --git a/packages/vitnode/src/components/form/fields/checkbox.tsx b/packages/vitnode/src/components/form/fields/checkbox.tsx index a48e1d99f8..187d2aed0b 100644 --- a/packages/vitnode/src/components/form/fields/checkbox.tsx +++ b/packages/vitnode/src/components/form/fields/checkbox.tsx @@ -12,6 +12,7 @@ export const AutoFormCheckbox = ({ label, labelRight, description, + tip, otherProps: { isOptional }, field, className, @@ -46,7 +47,11 @@ export const AutoFormCheckbox = ({ {!!(label ?? description) && (
{!!label && ( - + {label} )} diff --git a/packages/vitnode/src/components/form/fields/color.tsx b/packages/vitnode/src/components/form/fields/color.tsx index 22060cb1d9..d146365f11 100644 --- a/packages/vitnode/src/components/form/fields/color.tsx +++ b/packages/vitnode/src/components/form/fields/color.tsx @@ -18,6 +18,7 @@ export const AutoFormColor = ({ label, labelRight, description, + tip, otherProps: { isOptional }, field, // oxlint-disable-next-line no-unused-vars @@ -32,7 +33,11 @@ export const AutoFormColor = ({ return ( <> {!!label && ( - + {label} )} diff --git a/packages/vitnode/src/components/form/fields/combobox.tsx b/packages/vitnode/src/components/form/fields/combobox.tsx index 3ca4aa6606..50056eea62 100644 --- a/packages/vitnode/src/components/form/fields/combobox.tsx +++ b/packages/vitnode/src/components/form/fields/combobox.tsx @@ -353,6 +353,7 @@ export const AutoFormCombobox = ({ labels = [], labelRight, maxVisibleChips, + tip, onValueChange, onInputValueChange, disabled, @@ -404,6 +405,7 @@ export const AutoFormCombobox = ({ {label} diff --git a/packages/vitnode/src/components/form/fields/date-picker.tsx b/packages/vitnode/src/components/form/fields/date-picker.tsx index bec47c9c63..c12f7fa1c1 100644 --- a/packages/vitnode/src/components/form/fields/date-picker.tsx +++ b/packages/vitnode/src/components/form/fields/date-picker.tsx @@ -18,6 +18,7 @@ export const AutoFormDatePicker = ({ label, labelRight, description, + tip, field, onChange, allowClear, @@ -36,7 +37,7 @@ export const AutoFormDatePicker = ({ }) => ( <> {!!label && ( - + {label} )} diff --git a/packages/vitnode/src/components/form/fields/date-time.tsx b/packages/vitnode/src/components/form/fields/date-time.tsx index 7d3e47fca4..cdfb7cf8e7 100644 --- a/packages/vitnode/src/components/form/fields/date-time.tsx +++ b/packages/vitnode/src/components/form/fields/date-time.tsx @@ -31,6 +31,7 @@ export const AutoFormDateTime = ({ label, labelRight, description, + tip, field, // oxlint-disable-next-line no-unused-vars itemParams, @@ -49,7 +50,11 @@ export const AutoFormDateTime = ({ return ( <> {!!label && ( - + {label} )} diff --git a/packages/vitnode/src/components/form/fields/editor.tsx b/packages/vitnode/src/components/form/fields/editor.tsx index 6eac437e4c..96aadc7eeb 100644 --- a/packages/vitnode/src/components/form/fields/editor.tsx +++ b/packages/vitnode/src/components/form/fields/editor.tsx @@ -68,6 +68,7 @@ const MultiLangEditorLayout = ({ languages, onSelect, selected, + tip, }: { canSelect: boolean; children: React.ReactNode; @@ -78,6 +79,7 @@ const MultiLangEditorLayout = ({ languages: React.ComponentProps["languages"]; onSelect: (code: string) => void; selected: string; + tip?: React.ReactNode; }) => ( <> @@ -107,6 +110,7 @@ const MultiLangHtmlEditor = ({ description, isOptional, field, + tip, ...props }: MultiLangEditorProps) => { const { @@ -129,6 +133,7 @@ const MultiLangHtmlEditor = ({ languages={languages} onSelect={setSelected} selected={selected} + tip={tip} > | undefined> @@ -175,6 +181,7 @@ const MultiLangJsonEditor = ({ languages={languages} onSelect={setSelected} selected={selected} + tip={tip} > {collaborative ? ( { const labelledBy = useEditorLabelledBy(label); @@ -231,6 +239,7 @@ export const AutoFormEditor = ({ isOptional={isOptional} label={label} labelRight={labelRight} + tip={tip} {...props} /> ); @@ -244,6 +253,7 @@ export const AutoFormEditor = ({ isOptional={isOptional} label={label} labelRight={labelRight} + tip={tip} {...props} /> ); @@ -252,7 +262,11 @@ export const AutoFormEditor = ({ return ( <> {!!label && ( - + {label} )} diff --git a/packages/vitnode/src/components/form/fields/emoji-icon.tsx b/packages/vitnode/src/components/form/fields/emoji-icon.tsx index 66dfb811df..f896e2b8eb 100644 --- a/packages/vitnode/src/components/form/fields/emoji-icon.tsx +++ b/packages/vitnode/src/components/form/fields/emoji-icon.tsx @@ -18,6 +18,7 @@ export const AutoFormEmojiIcon = ({ label, labelRight, description, + tip, otherProps: { isOptional }, field, // oxlint-disable-next-line no-unused-vars @@ -30,7 +31,11 @@ export const AutoFormEmojiIcon = ({ return ( <> {!!label && ( - + {label} )} diff --git a/packages/vitnode/src/components/form/fields/file.tsx b/packages/vitnode/src/components/form/fields/file.tsx index 6345149a38..67cfd18744 100644 --- a/packages/vitnode/src/components/form/fields/file.tsx +++ b/packages/vitnode/src/components/form/fields/file.tsx @@ -193,6 +193,7 @@ export const AutoFormFile = ({ maxBytes, onUpload, otherProps: { isOptional }, + tip, // Only the language-aware inputs implement this - dropped here so it never // lands on the DOM element below. A file is never localized. // oxlint-disable-next-line no-unused-vars @@ -249,6 +250,8 @@ export const AutoFormFile = ({ className={cn(cover && "sr-only")} isOptional={isOptional} labelRight={labelRight} + // A cover hides the label visually; a lone help icon would read as noise. + tip={cover ? undefined : tip} > {label} diff --git a/packages/vitnode/src/components/form/fields/files.tsx b/packages/vitnode/src/components/form/fields/files.tsx index 0d7112e594..877daa629b 100644 --- a/packages/vitnode/src/components/form/fields/files.tsx +++ b/packages/vitnode/src/components/form/fields/files.tsx @@ -70,6 +70,7 @@ export const AutoFormFiles = ({ onUpload, ordered = true, otherProps: { isOptional }, + tip, // Only the language-aware inputs implement this - dropped here so it never // lands on the DOM element below. A file is never localized. // oxlint-disable-next-line no-unused-vars @@ -263,7 +264,11 @@ export const AutoFormFiles = ({ return ( <> {!!label && ( - + {label} )} diff --git a/packages/vitnode/src/components/form/fields/input-otp.tsx b/packages/vitnode/src/components/form/fields/input-otp.tsx index 8ebca9869a..308f4adeb9 100644 --- a/packages/vitnode/src/components/form/fields/input-otp.tsx +++ b/packages/vitnode/src/components/form/fields/input-otp.tsx @@ -42,6 +42,7 @@ export const AutoFormInputOTP = ({ label, labelRight, description, + tip, otherProps, field, groups, @@ -68,6 +69,7 @@ export const AutoFormInputOTP = ({ {label} diff --git a/packages/vitnode/src/components/form/fields/input-roles.tsx b/packages/vitnode/src/components/form/fields/input-roles.tsx index 4307f96528..fb2891541c 100644 --- a/packages/vitnode/src/components/form/fields/input-roles.tsx +++ b/packages/vitnode/src/components/form/fields/input-roles.tsx @@ -74,6 +74,7 @@ export const AutoFormRoles = ({ search, searchPlaceholder, selected = [], + tip, }: AutoFormRolesProps) => { const t = useTranslations("core.global"); const locale = useLocale(); @@ -102,7 +103,11 @@ export const AutoFormRoles = ({ }; const label_ = !!label && ( - + {label} ); diff --git a/packages/vitnode/src/components/form/fields/input-users.tsx b/packages/vitnode/src/components/form/fields/input-users.tsx index 8d09b08495..a3f629a6bd 100644 --- a/packages/vitnode/src/components/form/fields/input-users.tsx +++ b/packages/vitnode/src/components/form/fields/input-users.tsx @@ -113,6 +113,7 @@ export const AutoFormUser = ({ search, searchPlaceholder, selected, + tip, }: ItemAutoFormComponentProps & { className?: string; /** Adds a button that sets the field back to `null`. */ @@ -144,6 +145,7 @@ export const AutoFormUser = ({ {label} diff --git a/packages/vitnode/src/components/form/fields/input.tsx b/packages/vitnode/src/components/form/fields/input.tsx index 05b30b43e0..dc5cc3ec28 100644 --- a/packages/vitnode/src/components/form/fields/input.tsx +++ b/packages/vitnode/src/components/form/fields/input.tsx @@ -22,6 +22,7 @@ const MultiLangInput = ({ label, labelRight, description, + tip, isOptional, field, itemParams, @@ -55,6 +56,7 @@ const MultiLangInput = ({ languages={languages} onSelect={setSelected} selected={selected} + tip={tip} /> @@ -96,6 +98,7 @@ export const AutoFormInput = ({ label, labelRight, description, + tip, otherProps, field, itemParams, @@ -112,6 +115,7 @@ export const AutoFormInput = ({ itemParams={itemParams} label={label} labelRight={labelRight} + tip={tip} {...props} /> ); @@ -122,7 +126,11 @@ export const AutoFormInput = ({ return ( <> {!!label && ( - + {label} )} diff --git a/packages/vitnode/src/components/form/fields/multi-lang.tsx b/packages/vitnode/src/components/form/fields/multi-lang.tsx index 5ac08312bd..92ffb798f6 100644 --- a/packages/vitnode/src/components/form/fields/multi-lang.tsx +++ b/packages/vitnode/src/components/form/fields/multi-lang.tsx @@ -22,6 +22,7 @@ import { } from "@/lib/helpers/multi-lang"; import { AutoFormLabel } from "../common/label"; +import { hasTipContent } from "../common/tip"; import { useMultiLangDefaultLanguage } from "./multi-lang-default-language"; import { MultiLangSelectedContext, @@ -183,8 +184,9 @@ export const MultiLangSelect = ({ }; /** - * A multi-language field's label row: the label, plus the language switcher - * when `canSelect`. Renders nothing without either. + * A multi-language field's label row: the label (with its `tip`), plus the + * language switcher when `canSelect`. Renders nothing without either. The tip + * is one per field, not per language - pass it in the UI language. */ export const MultiLangLabel = ({ canSelect, @@ -194,19 +196,21 @@ export const MultiLangLabel = ({ languages, onSelect, selected, + tip, }: React.ComponentProps & { canSelect: boolean; isOptional?: boolean; label?: React.ReactNode; labelRight?: React.ReactNode; + tip?: React.ReactNode; }) => { if (!label && !canSelect) return null; return ( -
+
{!!label && ( ) : undefined } + tip={tip} > {label} diff --git a/packages/vitnode/src/components/form/fields/nullable-number.tsx b/packages/vitnode/src/components/form/fields/nullable-number.tsx index b7edfebb9f..b6d117a666 100644 --- a/packages/vitnode/src/components/form/fields/nullable-number.tsx +++ b/packages/vitnode/src/components/form/fields/nullable-number.tsx @@ -40,6 +40,7 @@ export const AutoFormNullableNumber = ({ label, labelRight, description, + tip, field, otherProps: { isOptional }, // oxlint-disable-next-line no-unused-vars @@ -66,7 +67,11 @@ export const AutoFormNullableNumber = ({ return ( <> {!!label && ( - + {label} )} diff --git a/packages/vitnode/src/components/form/fields/number.tsx b/packages/vitnode/src/components/form/fields/number.tsx index 69e9274e72..fa0a2a12c4 100644 --- a/packages/vitnode/src/components/form/fields/number.tsx +++ b/packages/vitnode/src/components/form/fields/number.tsx @@ -38,6 +38,7 @@ export const AutoFormNumber = ({ multiLang, otherProps: { isOptional }, placeholder, + tip, unitLabel, ...props }: AutoFormNumberProps) => { @@ -46,7 +47,11 @@ export const AutoFormNumber = ({ return ( <> {!!label && ( - + {label} )} diff --git a/packages/vitnode/src/components/form/fields/radio-group.tsx b/packages/vitnode/src/components/form/fields/radio-group.tsx index 5a7211ac48..4ba9f5c455 100644 --- a/packages/vitnode/src/components/form/fields/radio-group.tsx +++ b/packages/vitnode/src/components/form/fields/radio-group.tsx @@ -36,6 +36,7 @@ export const AutoFormRadioGroup = ({ description, otherProps: { enum: enumValues = [], isOptional }, labels = [], + tip, variant = "default", ...props }: ItemAutoFormComponentProps & @@ -59,7 +60,11 @@ export const AutoFormRadioGroup = ({ return ( <> {!!label && ( - + {label} )} diff --git a/packages/vitnode/src/components/form/fields/select.tsx b/packages/vitnode/src/components/form/fields/select.tsx index d9f85114fe..f118983bf7 100644 --- a/packages/vitnode/src/components/form/fields/select.tsx +++ b/packages/vitnode/src/components/form/fields/select.tsx @@ -34,6 +34,7 @@ export const AutoFormSelect = ({ placeholder, labelRight, labels = [], + tip, ...props }: ItemAutoFormComponentProps & Omit, "value"> & { @@ -54,7 +55,11 @@ export const AutoFormSelect = ({ return ( <> {!!label && ( - + {label} )} diff --git a/packages/vitnode/src/components/form/fields/switch.tsx b/packages/vitnode/src/components/form/fields/switch.tsx index c69d1045ef..6ea670aa69 100644 --- a/packages/vitnode/src/components/form/fields/switch.tsx +++ b/packages/vitnode/src/components/form/fields/switch.tsx @@ -31,6 +31,7 @@ export const AutoFormSwitch = ({ otherProps: { isOptional }, className, description, + tip, ...props }: ItemAutoFormComponentProps & Omit, "checked">) => { @@ -64,6 +65,7 @@ export const AutoFormSwitch = ({ id={labelId} isOptional={isOptional} labelRight={labelRight} + tip={tip} > {label} diff --git a/packages/vitnode/src/components/form/fields/textarea.tsx b/packages/vitnode/src/components/form/fields/textarea.tsx index 79bd997740..8ce8e7e73a 100644 --- a/packages/vitnode/src/components/form/fields/textarea.tsx +++ b/packages/vitnode/src/components/form/fields/textarea.tsx @@ -26,6 +26,7 @@ const MultiLangTextarea = ({ label, labelRight, description, + tip, isOptional, field, itemParams, @@ -57,6 +58,7 @@ const MultiLangTextarea = ({ languages={languages} onSelect={setSelected} selected={selected} + tip={tip} /> @@ -90,6 +92,7 @@ export const AutoFormTextarea = ({ label, description, labelRight, + tip, otherProps, field, itemParams, @@ -106,6 +109,7 @@ export const AutoFormTextarea = ({ itemParams={itemParams} label={label} labelRight={labelRight} + tip={tip} {...props} /> ); @@ -116,7 +120,11 @@ export const AutoFormTextarea = ({ return ( <> {!!label && ( - + {label} )} diff --git a/packages/vitnode/src/components/tiptap/suggestion-popup.ts b/packages/vitnode/src/components/tiptap/suggestion-popup.ts index 843b0233c7..1ff844bb31 100644 --- a/packages/vitnode/src/components/tiptap/suggestion-popup.ts +++ b/packages/vitnode/src/components/tiptap/suggestion-popup.ts @@ -5,7 +5,7 @@ import React from "react"; export const SUGGESTION_POPUP_WRAPPER_CLASS = "group/suggestion z-50"; export const SUGGESTION_POPUP_CLASS = - "bg-popover text-popover-foreground ring-foreground/10 animate-in fade-in-0 zoom-in-95 w-72 rounded-lg shadow-md ring-1 duration-100 motion-reduce:animate-none origin-top-left rtl:origin-top-right group-data-[side=top]/suggestion:origin-bottom-left rtl:group-data-[side=top]/suggestion:origin-bottom-right"; + "bg-popover/80 text-popover-foreground ring-foreground/10 backdrop-blur-sm animate-in fade-in-0 zoom-in-95 w-72 rounded-lg shadow-md ring-1 duration-100 motion-reduce:animate-none origin-top-left rtl:origin-top-right group-data-[side=top]/suggestion:origin-bottom-left rtl:group-data-[side=top]/suggestion:origin-bottom-right"; export const mountSuggestionPopup = ( mount: SuggestionProps["mount"], diff --git a/packages/vitnode/src/components/ui/combobox.tsx b/packages/vitnode/src/components/ui/combobox.tsx index 98376272f6..71a9b7b771 100644 --- a/packages/vitnode/src/components/ui/combobox.tsx +++ b/packages/vitnode/src/components/ui/combobox.tsx @@ -124,7 +124,7 @@ function ComboboxContent({ > - + diff --git a/packages/vitnode/src/components/ui/popover.tsx b/packages/vitnode/src/components/ui/popover.tsx index d17bfc3776..d9775625e2 100644 --- a/packages/vitnode/src/components/ui/popover.tsx +++ b/packages/vitnode/src/components/ui/popover.tsx @@ -44,7 +44,7 @@ function PopoverContent({ > +
{ return (
{ const t = useTranslations("admin.global"); return ( -
+
@@ -166,7 +163,7 @@ export const SearchAdminContent = ({ open={open} > diff --git a/packages/vitnode/src/views/admin/views/containers/container-select.tsx b/packages/vitnode/src/views/admin/views/containers/container-select.tsx index a8e9cadfcd..0e10b5cf28 100644 --- a/packages/vitnode/src/views/admin/views/containers/container-select.tsx +++ b/packages/vitnode/src/views/admin/views/containers/container-select.tsx @@ -46,9 +46,10 @@ export const AutoFormContainerSelect = ({ options, otherProps: { isOptional }, placeholder, + tip, }: Pick< ItemAutoFormComponentProps, - "description" | "field" | "label" | "labelRight" | "otherProps" + "description" | "field" | "label" | "labelRight" | "otherProps" | "tip" > & { disabled?: boolean; options: readonly ContainerSelectOption[]; @@ -70,7 +71,11 @@ export const AutoFormContainerSelect = ({ return ( <> {!!label && ( - + {label} )} diff --git a/packages/vitnode/src/views/auth/sign-up/components/password-input.tsx b/packages/vitnode/src/views/auth/sign-up/components/password-input.tsx index 752e15e25a..d1d847cf99 100644 --- a/packages/vitnode/src/views/auth/sign-up/components/password-input.tsx +++ b/packages/vitnode/src/views/auth/sign-up/components/password-input.tsx @@ -26,6 +26,7 @@ export const PasswordInput = ({ itemParams: _itemParams, field, otherProps: { isOptional, maxLength, minLength, pattern }, + tip, ...props }: ItemAutoFormComponentProps & Omit, "type">) => { @@ -53,7 +54,7 @@ export const PasswordInput = ({ return ( <> - + {label}