@eifi1/ui-kit 0.12.0 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -10
- package/dist/components/amount-input.d.ts +23 -1
- package/dist/components/amount-input.js +78 -13
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/bulk-action-bar.d.ts +10 -2
- package/dist/components/bulk-action-bar.js.map +1 -1
- package/dist/components/button-group.d.ts +1 -1
- package/dist/components/calculator.d.ts +1 -1
- package/dist/components/chart.js +14 -8
- package/dist/components/chart.js.map +1 -1
- package/dist/components/chip.js +20 -15
- package/dist/components/chip.js.map +1 -1
- package/dist/components/combobox-core.d.ts +3 -1
- package/dist/components/combobox-core.js +16 -5
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/copy-button.d.ts +1 -1
- package/dist/components/data-table-filter-popover.js +8 -1
- package/dist/components/data-table-filter-popover.js.map +1 -1
- package/dist/components/data-table.js +28 -20
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/entity-combobox.js +22 -17
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/error-boundary.d.ts +202 -13
- package/dist/components/error-boundary.js +225 -21
- package/dist/components/error-boundary.js.map +1 -1
- package/dist/components/field-sync.js +90 -83
- package/dist/components/field-sync.js.map +1 -1
- package/dist/components/file-button.d.ts +1 -1
- package/dist/components/file-dropzone.d.ts +1 -1
- package/dist/components/form-actions.d.ts +1 -1
- package/dist/components/full-bleed-dialog.js +4 -0
- package/dist/components/full-bleed-dialog.js.map +1 -1
- package/dist/components/hover-menu.js +64 -56
- package/dist/components/hover-menu.js.map +1 -1
- package/dist/components/list.d.ts +14 -2
- package/dist/components/list.js +3 -1
- package/dist/components/list.js.map +1 -1
- package/dist/components/modal.js.map +1 -1
- package/dist/components/multi-entity-combobox.js +23 -17
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/number-field.d.ts +1 -1
- package/dist/components/number-input.d.ts +1 -1
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +10 -2
- package/dist/components/numpad-sheet.js +6 -4
- package/dist/components/numpad-sheet.js.map +1 -1
- package/dist/components/page-header.d.ts +35 -6
- package/dist/components/page-header.js +7 -1
- package/dist/components/page-header.js.map +1 -1
- package/dist/components/picker-sheet.js +3 -0
- package/dist/components/picker-sheet.js.map +1 -1
- package/dist/components/popover.js +3 -0
- package/dist/components/popover.js.map +1 -1
- package/dist/components/settings-fields.d.ts +1 -1
- package/dist/components/signed-amount.d.ts +2 -1
- package/dist/components/signed-amount.js +1 -1
- package/dist/components/signed-amount.js.map +1 -1
- package/dist/components/stat-tile.js +1 -1
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/swipeable-row.js +4 -3
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/text-link.d.ts +73 -11
- package/dist/components/text-link.js +45 -10
- package/dist/components/text-link.js.map +1 -1
- package/dist/components/text.d.ts +26 -2
- package/dist/components/text.js +16 -2
- package/dist/components/text.js.map +1 -1
- package/dist/components/time-input.d.ts +1 -1
- package/dist/components/toggle-group.d.ts +22 -0
- package/dist/components/toggle-group.js +8 -8
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/toggle-legend.d.ts +17 -0
- package/dist/components/toggle-legend.js +6 -2
- package/dist/components/toggle-legend.js.map +1 -1
- package/dist/components/tooltip.d.ts +51 -1
- package/dist/components/tooltip.js +59 -35
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/ui.d.ts +1 -1
- package/dist/components/ui.js +166 -28
- package/dist/components/ui.js.map +1 -1
- package/dist/feedback/feedback-attachment.js +4 -6
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-dialog.js +5 -3
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.js +86 -75
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback/feedback-thread.d.ts +43 -3
- package/dist/feedback/feedback-thread.js +53 -26
- package/dist/feedback/feedback-thread.js.map +1 -1
- package/dist/feedback.d.ts +1 -1
- package/dist/hooks/use-file-drop.d.ts +1 -1
- package/dist/i18n/defaults.d.ts +1 -1
- package/dist/i18n/kit-labels.d.ts +1 -1
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +1 -1
- package/dist/i18n/locales/de-CH.d.ts +1 -1
- package/dist/i18n/locales/de-informal.d.ts +1 -1
- package/dist/i18n/locales/de.d.ts +1 -1
- package/dist/i18n/locales/de.js +9 -1
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +1 -1
- package/dist/i18n/locales/es.js +9 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +1 -1
- package/dist/i18n/locales/fr.js +9 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +1 -1
- package/dist/i18n/locales/hu.js +9 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +1 -1
- package/dist/i18n/locales/it.js +9 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +1 -1
- package/dist/i18n/locales/zh.js +9 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +6 -6
- package/dist/{kit-labels-v3biUF1L.d.ts → kit-labels-Mz8zZHIF.d.ts} +102 -3
- package/dist/lib/format.d.ts +23 -5
- package/dist/lib/format.js +4 -2
- package/dist/lib/format.js.map +1 -1
- package/dist/rhf/fields.d.ts +59 -15
- package/dist/rhf/fields.js +41 -11
- package/dist/rhf/fields.js.map +1 -1
- package/dist/rhf/form.d.ts +1 -1
- package/dist/rhf.d.ts +2 -2
- package/dist/rhf.js.map +1 -1
- package/dist/search/command-palette.js +20 -14
- package/dist/search/command-palette.js.map +1 -1
- package/dist/shell/app-shell.d.ts +1 -1
- package/dist/shell/app-shell.js +62 -54
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/top-bar-brand.d.ts +1 -1
- package/dist/shell/topbar-action-menu.d.ts +9 -3
- package/dist/shell/topbar-action-menu.js +2 -1
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell.d.ts +1 -1
- package/dist/tour/tour.js +10 -4
- package/dist/tour/tour.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +1 -1
- package/dist/wizard/use-wizard.js +4 -2
- package/dist/wizard/use-wizard.js.map +1 -1
- package/dist/wizard/wizard-step.d.ts +11 -1
- package/dist/wizard/wizard-step.js +24 -6
- package/dist/wizard/wizard-step.js.map +1 -1
- package/dist/wizard.d.ts +1 -1
- package/package.json +6 -6
- package/src/components/amount-input.tsx +165 -12
- package/src/components/bulk-action-bar.tsx +16 -3
- package/src/components/chart.tsx +19 -8
- package/src/components/chip.tsx +6 -0
- package/src/components/combobox-core.tsx +28 -9
- package/src/components/combobox.tsx +2 -0
- package/src/components/data-table-filter-popover.tsx +13 -5
- package/src/components/data-table.tsx +6 -0
- package/src/components/entity-combobox.tsx +7 -4
- package/src/components/error-boundary.tsx +470 -30
- package/src/components/field-sync.tsx +48 -40
- package/src/components/full-bleed-dialog.tsx +4 -0
- package/src/components/hover-menu.tsx +26 -17
- package/src/components/list.tsx +17 -3
- package/src/components/modal.tsx +4 -0
- package/src/components/multi-entity-combobox.tsx +8 -4
- package/src/components/number-input.tsx +1 -0
- package/src/components/numpad-sheet.tsx +17 -3
- package/src/components/page-header.tsx +42 -5
- package/src/components/picker-sheet.tsx +3 -0
- package/src/components/popover.tsx +3 -0
- package/src/components/signed-amount.tsx +12 -3
- package/src/components/stat-tile.tsx +3 -1
- package/src/components/swipeable-row.tsx +7 -7
- package/src/components/text-link.tsx +126 -18
- package/src/components/text.tsx +47 -2
- package/src/components/toggle-group.tsx +35 -10
- package/src/components/toggle-legend.tsx +22 -1
- package/src/components/tooltip.tsx +79 -8
- package/src/components/ui.tsx +307 -39
- package/src/feedback/feedback-attachment.tsx +10 -7
- package/src/feedback/feedback-dialog.tsx +7 -4
- package/src/feedback/feedback-inbox.tsx +7 -0
- package/src/feedback/feedback-thread.tsx +109 -33
- package/src/i18n/kit-labels.tsx +16 -0
- package/src/i18n/locales/de.ts +8 -0
- package/src/i18n/locales/es.ts +8 -0
- package/src/i18n/locales/fr.ts +8 -0
- package/src/i18n/locales/hu.ts +8 -0
- package/src/i18n/locales/it.ts +8 -0
- package/src/i18n/locales/zh.ts +8 -0
- package/src/lib/format.ts +32 -7
- package/src/rhf/fields.tsx +99 -16
- package/src/rhf.ts +1 -1
- package/src/search/command-palette.tsx +29 -19
- package/src/shell/app-shell.tsx +6 -1
- package/src/shell/topbar-action-menu.tsx +11 -4
- package/src/tour/tour.tsx +16 -5
- package/src/wizard/use-wizard.ts +8 -4
- package/src/wizard/wizard-step.tsx +38 -6
package/src/components/ui.tsx
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { forwardRef, useId, useLayoutEffect, useRef, useState } from "react";
|
|
2
2
|
import { ChevronDown, Eye, EyeOff, HelpCircle, Plus, X } from "lucide-react";
|
|
3
|
-
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ComponentPropsWithoutRef, InputHTMLAttributes, KeyboardEvent, MouseEvent, ReactElement, ReactNode, Ref, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
|
|
3
|
+
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ComponentPropsWithoutRef, CSSProperties, InputHTMLAttributes, KeyboardEvent, MouseEvent, ReactElement, ReactNode, Ref, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
|
|
4
4
|
import { cn } from "../lib/cn";
|
|
5
5
|
import { horizontalStep } from "../lib/direction";
|
|
6
6
|
import { useMediaQuery } from "../hooks/use-media-query";
|
|
7
7
|
import { Tooltip, type TooltipSide } from "./tooltip";
|
|
8
8
|
import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLink } from "../i18n/kit-labels";
|
|
9
9
|
import type { KitLinkComponent, KitLinkProps } from "../i18n/kit-labels";
|
|
10
|
-
import { pickLinkRenderer } from "./text-link";
|
|
10
|
+
import { pickLinkRenderer, replacingClick, routerLinkNavigation } from "./text-link";
|
|
11
11
|
|
|
12
12
|
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "brand" | "link";
|
|
13
13
|
|
|
@@ -214,6 +214,8 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
|
214
214
|
href?: never;
|
|
215
215
|
renderLink?: never;
|
|
216
216
|
external?: never;
|
|
217
|
+
replace?: never;
|
|
218
|
+
reloadDocument?: never;
|
|
217
219
|
}
|
|
218
220
|
|
|
219
221
|
/**
|
|
@@ -243,6 +245,21 @@ export interface ButtonLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorEle
|
|
|
243
245
|
* router link, and "(opens in a new tab)" read after the label — billing-history's
|
|
244
246
|
* Stripe PDF. A `target="_blank"` of your own is announced the same way. */
|
|
245
247
|
external?: boolean;
|
|
248
|
+
/**
|
|
249
|
+
* Navigate by replacing the current history entry, so Back does not return here —
|
|
250
|
+
* keksdose F1: the "Continue" out of a one-shot page (an emailed confirmation link, a
|
|
251
|
+
* payment return URL) that must not be landed on again. Handed to the router link as
|
|
252
|
+
* `replace` (see `KitLinkProps.replace` for mapping it); on a plain `<a>` a plain click
|
|
253
|
+
* becomes `location.replace(href)`. Ignored when `external`.
|
|
254
|
+
*/
|
|
255
|
+
replace?: boolean;
|
|
256
|
+
/**
|
|
257
|
+
* A plain `<a>` for an in-app `href`, so the browser loads the whole document instead
|
|
258
|
+
* of the router swapping the view — keksdose F1: a route the SPA does not own (a
|
|
259
|
+
* server-rendered export, a logout that must drop every in-memory cache). Wins over
|
|
260
|
+
* `renderLink` and the provider's `linkComponent`.
|
|
261
|
+
*/
|
|
262
|
+
reloadDocument?: boolean;
|
|
246
263
|
/** A link cannot be `disabled`, so this renders an `<a>` with NO `href` —
|
|
247
264
|
* `role="link"` and `aria-disabled`, out of the tab order like a disabled button,
|
|
248
265
|
* with the disabled look. The router link is not used: it needs somewhere to go. */
|
|
@@ -294,6 +311,8 @@ function ButtonLink({
|
|
|
294
311
|
href,
|
|
295
312
|
renderLink,
|
|
296
313
|
external,
|
|
314
|
+
replace,
|
|
315
|
+
reloadDocument,
|
|
297
316
|
disabled,
|
|
298
317
|
variant = "primary",
|
|
299
318
|
size = "md",
|
|
@@ -311,7 +330,8 @@ function ButtonLink({
|
|
|
311
330
|
// The same rule as every kit link (`pickLinkRenderer`): an external `href` or an
|
|
312
331
|
// in-page `#anchor` is never handed to the provider's router link, even without
|
|
313
332
|
// `external`.
|
|
314
|
-
|
|
333
|
+
// `reloadDocument` is the caller saying "not the router" for this one link.
|
|
334
|
+
const Link = reloadDocument ? undefined : pickLinkRenderer<KitLinkProps>(renderLink, kitLink, href);
|
|
315
335
|
const newTab = !disabled && (external || target === "_blank");
|
|
316
336
|
const cls = cn(
|
|
317
337
|
buttonLook(variant, size, stretch, tone),
|
|
@@ -346,7 +366,7 @@ function ButtonLink({
|
|
|
346
366
|
href={href}
|
|
347
367
|
target={external ? "_blank" : target}
|
|
348
368
|
rel={external ? (rel ?? "noopener noreferrer") : rel}
|
|
349
|
-
onClick={onClick}
|
|
369
|
+
onClick={external ? onClick : replacingClick(onClick, href, replace, target)}
|
|
350
370
|
className={cls}
|
|
351
371
|
>
|
|
352
372
|
{body}
|
|
@@ -357,6 +377,7 @@ function ButtonLink({
|
|
|
357
377
|
<RenderedButtonLink
|
|
358
378
|
render={Link}
|
|
359
379
|
{...rest}
|
|
380
|
+
{...routerLinkNavigation(replace)}
|
|
360
381
|
href={href}
|
|
361
382
|
target={target}
|
|
362
383
|
rel={rel}
|
|
@@ -382,6 +403,8 @@ function ButtonElement({
|
|
|
382
403
|
href: _href,
|
|
383
404
|
renderLink: _renderLink,
|
|
384
405
|
external: _external,
|
|
406
|
+
replace: _replace,
|
|
407
|
+
reloadDocument: _reloadDocument,
|
|
385
408
|
...rest
|
|
386
409
|
}: ButtonProps) {
|
|
387
410
|
const reasonId = useId();
|
|
@@ -687,8 +710,12 @@ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
|
|
|
687
710
|
* state re-colours the glyph, its hover fill and its focus ring together. Passing it
|
|
688
711
|
* implies `tone="custom"`. Or leave it out and set the variable yourself from a class
|
|
689
712
|
* (`className="[--icon-button-tone:var(--success)]"`).
|
|
713
|
+
*
|
|
714
|
+
* Or a `{ light, dark }` pair, one colour per theme — keksdose: a raw colour that reads
|
|
715
|
+
* on the light surface (`#be123c`) is too dark on the dark one, and an inline style
|
|
716
|
+
* cannot say "in dark mode". See {@link IconButtonToneColor}.
|
|
690
717
|
*/
|
|
691
|
-
toneColor?:
|
|
718
|
+
toneColor?: IconButtonToneColor;
|
|
692
719
|
/** Glyph colour over the variant. `muted`: placeholder grey, full text colour on
|
|
693
720
|
* hover. `danger`: the same grey at rest, `--danger` on hover and focus — for a
|
|
694
721
|
* remove/delete that repeats down a list. `warning`: amber at rest — a flag that
|
|
@@ -796,9 +823,272 @@ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
|
|
|
796
823
|
* reason nobody can see is not one. Wins over `disabled`, as on Button.
|
|
797
824
|
*/
|
|
798
825
|
disabledReason?: ReactNode;
|
|
826
|
+
/** The `<button>` element — a prop in React 19, as on {@link Button}. */
|
|
827
|
+
ref?: Ref<HTMLButtonElement>;
|
|
828
|
+
/** Only on the link form — see {@link IconButtonLinkProps}. */
|
|
829
|
+
href?: never;
|
|
830
|
+
renderLink?: never;
|
|
831
|
+
external?: never;
|
|
832
|
+
replace?: never;
|
|
833
|
+
reloadDocument?: never;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* `<IconButton href>`: an icon-only LINK — keksdose F2 and kastlan 45, whose icon
|
|
838
|
+
* actions that navigate (a row's "open", a card's "edit" that goes to the edit page, a
|
|
839
|
+
* top bar's settings cog) were `<IconButton onClick={() => navigate(…)}>`: a button that
|
|
840
|
+
* changes the page, with no middle click, no "open in new tab" and no URL on hover.
|
|
841
|
+
*
|
|
842
|
+
* The same rule as {@link Button}'s `href` (see {@link ButtonLinkProps}): an in-app
|
|
843
|
+
* `href` goes through `renderLink`, else the provider's `linkComponent`; an external one
|
|
844
|
+
* or an in-page `#anchor` stays a plain `<a>`; `external` opens a new tab and says so to
|
|
845
|
+
* a screen reader; `replace` and `reloadDocument` as on Button. The look, the `label` →
|
|
846
|
+
* `aria-label` + Tooltip, the badge, `toneColor` and `stopPropagation` are the button's.
|
|
847
|
+
*
|
|
848
|
+
* `disabled` renders an inert `<a>` with no `href` — `role="link"`, `aria-disabled`, out
|
|
849
|
+
* of the tab order, dimmed — like Button's disabled link. `pressed`, `disabledReason` and
|
|
850
|
+
* `disabledStyle` are a button's and are typed out: a link is not a toggle, and a link
|
|
851
|
+
* that explains why it cannot be followed is a job for a disabled BUTTON.
|
|
852
|
+
*/
|
|
853
|
+
export interface IconButtonLinkProps
|
|
854
|
+
extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "type" | "href">,
|
|
855
|
+
Pick<
|
|
856
|
+
IconButtonProps,
|
|
857
|
+
| "variant"
|
|
858
|
+
| "size"
|
|
859
|
+
| "glyphSize"
|
|
860
|
+
| "badge"
|
|
861
|
+
| "stretch"
|
|
862
|
+
| "shape"
|
|
863
|
+
| "toneColor"
|
|
864
|
+
| "tone"
|
|
865
|
+
| "quiet"
|
|
866
|
+
| "stopPropagation"
|
|
867
|
+
| "label"
|
|
868
|
+
| "tooltip"
|
|
869
|
+
| "tooltipSide"
|
|
870
|
+
| "tooltipPortal"
|
|
871
|
+
> {
|
|
872
|
+
href: string;
|
|
873
|
+
/** See {@link ButtonLinkProps.renderLink}. */
|
|
874
|
+
renderLink?: KitLinkComponent;
|
|
875
|
+
/** See {@link ButtonLinkProps.external}. */
|
|
876
|
+
external?: boolean;
|
|
877
|
+
/** See {@link ButtonLinkProps.replace}. */
|
|
878
|
+
replace?: boolean;
|
|
879
|
+
/** See {@link ButtonLinkProps.reloadDocument}. */
|
|
880
|
+
reloadDocument?: boolean;
|
|
881
|
+
/** See {@link ButtonLinkProps.disabled}. */
|
|
882
|
+
disabled?: boolean;
|
|
883
|
+
ref?: Ref<HTMLAnchorElement>;
|
|
884
|
+
type?: never;
|
|
885
|
+
form?: never;
|
|
886
|
+
pressed?: never;
|
|
887
|
+
disabledReason?: never;
|
|
888
|
+
disabledStyle?: never;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* `toneColor`: one CSS colour, or one per theme.
|
|
893
|
+
*
|
|
894
|
+
* The pair is two custom properties on the element — `--icon-button-tone-light` and
|
|
895
|
+
* `--icon-button-tone-dark` — and a class that points `--icon-button-tone` at one or the
|
|
896
|
+
* other by the kit's `dark` variant (`.dark` on an ancestor, tokens.css), so the colour
|
|
897
|
+
* follows a theme switch in CSS alone, with no re-render and no JS reading the theme.
|
|
898
|
+
* Both values are any CSS colour; a token that already flips (`var(--warning)`) needs no
|
|
899
|
+
* pair.
|
|
900
|
+
*/
|
|
901
|
+
export type IconButtonToneColor = string | { light: string; dark: string };
|
|
902
|
+
|
|
903
|
+
const ICON_BUTTON_TONE_PAIR =
|
|
904
|
+
"[--icon-button-tone:var(--icon-button-tone-light)] dark:[--icon-button-tone:var(--icon-button-tone-dark)]";
|
|
905
|
+
|
|
906
|
+
/** The inline custom properties `toneColor` sets over the caller's `style`. */
|
|
907
|
+
function toneColorStyle(toneColor: IconButtonToneColor | undefined, style: CSSProperties | undefined) {
|
|
908
|
+
if (toneColor === undefined) return style;
|
|
909
|
+
const vars: Record<string, string> =
|
|
910
|
+
typeof toneColor === "string"
|
|
911
|
+
? { "--icon-button-tone": toneColor }
|
|
912
|
+
: { "--icon-button-tone-light": toneColor.light, "--icon-button-tone-dark": toneColor.dark };
|
|
913
|
+
return { ...style, ...vars } as CSSProperties;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/** Everything but the element: the look both forms of IconButton share. */
|
|
917
|
+
function iconButtonLook({
|
|
918
|
+
variant,
|
|
919
|
+
size,
|
|
920
|
+
stretch,
|
|
921
|
+
glyphSize,
|
|
922
|
+
shape,
|
|
923
|
+
tone,
|
|
924
|
+
quiet,
|
|
925
|
+
toneColor,
|
|
926
|
+
}: {
|
|
927
|
+
variant: IconButtonVariant;
|
|
928
|
+
size: IconButtonSize;
|
|
929
|
+
stretch: boolean | undefined;
|
|
930
|
+
glyphSize: IconButtonGlyphSize | undefined;
|
|
931
|
+
shape: "square" | "round";
|
|
932
|
+
tone: IconButtonTone;
|
|
933
|
+
quiet: boolean | undefined;
|
|
934
|
+
toneColor: IconButtonToneColor | undefined;
|
|
935
|
+
}) {
|
|
936
|
+
return cn(
|
|
937
|
+
ICON_BUTTON_BASE,
|
|
938
|
+
stretch ? ICON_BUTTON_STRETCH_SIZES[size] : ICON_BUTTON_SIZES[size],
|
|
939
|
+
glyphSize !== undefined && ICON_BUTTON_GLYPH_SIZES[glyphSize],
|
|
940
|
+
// After the size, so the overlay's `rounded-full` beats the small sizes' `rounded`.
|
|
941
|
+
variant === "overlay"
|
|
942
|
+
? ICON_BUTTON_OVERLAY
|
|
943
|
+
: variant === "shutter"
|
|
944
|
+
? ICON_BUTTON_SHUTTER
|
|
945
|
+
: buttonVariantClasses[variant],
|
|
946
|
+
shape === "round" && "rounded-full",
|
|
947
|
+
ICON_BUTTON_DISABLED_REST[variant],
|
|
948
|
+
iconButtonToneClass(tone, quiet),
|
|
949
|
+
toneColor !== undefined && typeof toneColor !== "string" && ICON_BUTTON_TONE_PAIR,
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/** The badge beside the control, not in it — see `badge`. The wrapper is only a
|
|
954
|
+
* positioning box: the control inside it keeps its own focus ring and hit area. */
|
|
955
|
+
function withIconButtonBadge(control: ReactElement, badge: ReactNode, stretch: boolean | undefined) {
|
|
956
|
+
if (!hasContent(badge)) return control;
|
|
957
|
+
return (
|
|
958
|
+
<span className={cn("relative inline-flex shrink-0", stretch && "self-stretch")}>
|
|
959
|
+
{control}
|
|
960
|
+
<span
|
|
961
|
+
aria-hidden
|
|
962
|
+
data-slot="icon-button-badge"
|
|
963
|
+
className="pointer-events-none absolute -end-0.5 -bottom-0.5 inline-flex rounded-full bg-[var(--bg-surface)] [&_svg]:size-3"
|
|
964
|
+
>
|
|
965
|
+
{badge}
|
|
966
|
+
</span>
|
|
967
|
+
</span>
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// Declared link-first, as Button is, so `<IconButton href>` picks the link props and
|
|
972
|
+
// `ComponentProps<typeof IconButton>` (the LAST signature) is still the button's.
|
|
973
|
+
export function IconButton(props: IconButtonLinkProps): ReactElement;
|
|
974
|
+
export function IconButton(props: IconButtonProps): ReactElement;
|
|
975
|
+
export function IconButton(props: IconButtonProps | IconButtonLinkProps): ReactElement {
|
|
976
|
+
if (props.href !== undefined) return <IconButtonLink {...(props as IconButtonLinkProps)} />;
|
|
977
|
+
return <IconButtonElement {...(props as IconButtonProps)} />;
|
|
978
|
+
}
|
|
979
|
+
IconButton.displayName = "IconButton";
|
|
980
|
+
|
|
981
|
+
function IconButtonLink({
|
|
982
|
+
href,
|
|
983
|
+
renderLink,
|
|
984
|
+
external,
|
|
985
|
+
replace,
|
|
986
|
+
reloadDocument,
|
|
987
|
+
disabled,
|
|
988
|
+
variant = "ghost",
|
|
989
|
+
size = "md",
|
|
990
|
+
tone: toneProp,
|
|
991
|
+
quiet,
|
|
992
|
+
stopPropagation,
|
|
993
|
+
stretch,
|
|
994
|
+
shape = "square",
|
|
995
|
+
toneColor,
|
|
996
|
+
label,
|
|
997
|
+
tooltip = true,
|
|
998
|
+
tooltipSide,
|
|
999
|
+
tooltipPortal,
|
|
1000
|
+
glyphSize,
|
|
1001
|
+
badge,
|
|
1002
|
+
className,
|
|
1003
|
+
style,
|
|
1004
|
+
target,
|
|
1005
|
+
rel,
|
|
1006
|
+
onClick,
|
|
1007
|
+
onKeyDown,
|
|
1008
|
+
children,
|
|
1009
|
+
ref,
|
|
1010
|
+
...rest
|
|
1011
|
+
}: IconButtonLinkProps) {
|
|
1012
|
+
const kitLink = useKitLink();
|
|
1013
|
+
const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
|
|
1014
|
+
const tone = toneProp ?? (toneColor !== undefined ? "custom" : "default");
|
|
1015
|
+
// Button's rule, word for word: `reloadDocument` bypasses the router, an external
|
|
1016
|
+
// href or an in-page `#anchor` never reaches it.
|
|
1017
|
+
const Link = reloadDocument ? undefined : pickLinkRenderer<KitLinkProps>(renderLink, kitLink, href);
|
|
1018
|
+
const newTab = !disabled && (external || target === "_blank");
|
|
1019
|
+
const name = rest["aria-label"] ?? label;
|
|
1020
|
+
// "(opens in a new tab)" joins the NAME here, not the body: an icon link has no text
|
|
1021
|
+
// for an sr-only span to follow, and `aria-label` would override one anyway.
|
|
1022
|
+
const ariaLabel = name !== undefined && newTab ? `${name} (${common.opensInNewTab})` : name;
|
|
1023
|
+
const handleClick = (e: MouseEvent<HTMLAnchorElement>) => {
|
|
1024
|
+
if (stopPropagation) e.stopPropagation();
|
|
1025
|
+
onClick?.(e);
|
|
1026
|
+
};
|
|
1027
|
+
const handleKeyDown = (e: KeyboardEvent<HTMLAnchorElement>) => {
|
|
1028
|
+
// A link activates on Enter only; that key still bubbles to a clickable row.
|
|
1029
|
+
if (stopPropagation && e.key === "Enter") e.stopPropagation();
|
|
1030
|
+
onKeyDown?.(e);
|
|
1031
|
+
};
|
|
1032
|
+
const cls = cn(
|
|
1033
|
+
iconButtonLook({ variant, size, stretch, glyphSize, shape, tone, quiet, toneColor }),
|
|
1034
|
+
// BASE's `disabled:` classes never match an `<a>` — Button's disabled link look.
|
|
1035
|
+
disabled && "pointer-events-none cursor-not-allowed opacity-50",
|
|
1036
|
+
className,
|
|
1037
|
+
);
|
|
1038
|
+
const look = { style: toneColorStyle(toneColor, style), className: cls };
|
|
1039
|
+
let control: ReactElement;
|
|
1040
|
+
if (disabled) {
|
|
1041
|
+
control = (
|
|
1042
|
+
<a {...rest} {...look} ref={ref} role="link" aria-disabled="true" aria-label={ariaLabel}>
|
|
1043
|
+
{children}
|
|
1044
|
+
</a>
|
|
1045
|
+
);
|
|
1046
|
+
} else if (external || !Link) {
|
|
1047
|
+
control = (
|
|
1048
|
+
<a
|
|
1049
|
+
{...rest}
|
|
1050
|
+
{...look}
|
|
1051
|
+
ref={ref}
|
|
1052
|
+
href={href}
|
|
1053
|
+
aria-label={ariaLabel}
|
|
1054
|
+
target={external ? "_blank" : target}
|
|
1055
|
+
rel={external ? (rel ?? "noopener noreferrer") : rel}
|
|
1056
|
+
onClick={external ? handleClick : replacingClick(handleClick, href, replace, target)}
|
|
1057
|
+
onKeyDown={handleKeyDown}
|
|
1058
|
+
>
|
|
1059
|
+
{children}
|
|
1060
|
+
</a>
|
|
1061
|
+
);
|
|
1062
|
+
} else {
|
|
1063
|
+
control = (
|
|
1064
|
+
<RenderedButtonLink
|
|
1065
|
+
render={Link}
|
|
1066
|
+
{...rest}
|
|
1067
|
+
{...look}
|
|
1068
|
+
{...routerLinkNavigation(replace)}
|
|
1069
|
+
ref={ref}
|
|
1070
|
+
href={href}
|
|
1071
|
+
aria-label={ariaLabel}
|
|
1072
|
+
target={target}
|
|
1073
|
+
rel={rel}
|
|
1074
|
+
onClick={handleClick}
|
|
1075
|
+
onKeyDown={handleKeyDown}
|
|
1076
|
+
>
|
|
1077
|
+
{children}
|
|
1078
|
+
</RenderedButtonLink>
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
control = withIconButtonBadge(control, badge, stretch);
|
|
1082
|
+
if (!tooltip || label === undefined || label === "") return control;
|
|
1083
|
+
return (
|
|
1084
|
+
// A fragment, as on the button: the bubble says what `aria-label` already does.
|
|
1085
|
+
<Tooltip label={label} side={tooltipSide} portal={tooltipPortal} className={stretch ? "self-stretch" : undefined}>
|
|
1086
|
+
<>{control}</>
|
|
1087
|
+
</Tooltip>
|
|
1088
|
+
);
|
|
799
1089
|
}
|
|
800
1090
|
|
|
801
|
-
|
|
1091
|
+
function IconButtonElement(
|
|
802
1092
|
{
|
|
803
1093
|
variant = "ghost",
|
|
804
1094
|
size = "md",
|
|
@@ -821,9 +1111,14 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
|
|
821
1111
|
style,
|
|
822
1112
|
onClick,
|
|
823
1113
|
onKeyDown,
|
|
1114
|
+
href: _href,
|
|
1115
|
+
renderLink: _renderLink,
|
|
1116
|
+
external: _external,
|
|
1117
|
+
replace: _replace,
|
|
1118
|
+
reloadDocument: _reloadDocument,
|
|
1119
|
+
ref,
|
|
824
1120
|
...rest
|
|
825
|
-
},
|
|
826
|
-
ref,
|
|
1121
|
+
}: IconButtonProps,
|
|
827
1122
|
) {
|
|
828
1123
|
const tone = toneProp ?? (toneColor !== undefined ? "custom" : "default");
|
|
829
1124
|
const keep = disabledStyle === "keep";
|
|
@@ -839,7 +1134,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
|
|
839
1134
|
aria-describedby={locked ? (ownDescribedBy ? `${ownDescribedBy} ${reasonId}` : reasonId) : ownDescribedBy}
|
|
840
1135
|
aria-label={rest["aria-label"] ?? label}
|
|
841
1136
|
aria-pressed={pressed ?? rest["aria-pressed"]}
|
|
842
|
-
style={toneColor
|
|
1137
|
+
style={toneColorStyle(toneColor, style)}
|
|
843
1138
|
onClick={(e) => {
|
|
844
1139
|
if (stopPropagation) e.stopPropagation();
|
|
845
1140
|
// `preventDefault` too: a locked submit must not submit its form.
|
|
@@ -856,18 +1151,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
|
|
856
1151
|
onKeyDown?.(e);
|
|
857
1152
|
}}
|
|
858
1153
|
className={cn(
|
|
859
|
-
|
|
860
|
-
stretch ? ICON_BUTTON_STRETCH_SIZES[size] : ICON_BUTTON_SIZES[size],
|
|
861
|
-
glyphSize !== undefined && ICON_BUTTON_GLYPH_SIZES[glyphSize],
|
|
862
|
-
// After the size, so the overlay's `rounded-full` beats the small sizes' `rounded`.
|
|
863
|
-
variant === "overlay"
|
|
864
|
-
? ICON_BUTTON_OVERLAY
|
|
865
|
-
: variant === "shutter"
|
|
866
|
-
? ICON_BUTTON_SHUTTER
|
|
867
|
-
: buttonVariantClasses[variant],
|
|
868
|
-
shape === "round" && "rounded-full",
|
|
869
|
-
ICON_BUTTON_DISABLED_REST[variant],
|
|
870
|
-
iconButtonToneClass(tone, quiet),
|
|
1154
|
+
iconButtonLook({ variant, size, stretch, glyphSize, shape, tone, quiet, toneColor }),
|
|
871
1155
|
pressed && ICON_BUTTON_PRESSED,
|
|
872
1156
|
// The disabled look for the focusable kind of disabled, as on Button.
|
|
873
1157
|
locked && (keep ? "cursor-default" : "cursor-not-allowed opacity-50"),
|
|
@@ -876,22 +1160,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
|
|
876
1160
|
)}
|
|
877
1161
|
/>
|
|
878
1162
|
);
|
|
879
|
-
|
|
880
|
-
// positioning box: the button inside it keeps its own focus ring and hit area.
|
|
881
|
-
const control = hasContent(badge) ? (
|
|
882
|
-
<span className={cn("relative inline-flex shrink-0", stretch && "self-stretch")}>
|
|
883
|
-
{button}
|
|
884
|
-
<span
|
|
885
|
-
aria-hidden
|
|
886
|
-
data-slot="icon-button-badge"
|
|
887
|
-
className="pointer-events-none absolute -end-0.5 -bottom-0.5 inline-flex rounded-full bg-[var(--bg-surface)] [&_svg]:size-3"
|
|
888
|
-
>
|
|
889
|
-
{badge}
|
|
890
|
-
</span>
|
|
891
|
-
</span>
|
|
892
|
-
) : (
|
|
893
|
-
button
|
|
894
|
-
);
|
|
1163
|
+
const control = withIconButtonBadge(button, badge, stretch);
|
|
895
1164
|
if (locked) {
|
|
896
1165
|
return (
|
|
897
1166
|
// Button's shape: the button and the hidden reason in a FRAGMENT, so Tooltip does
|
|
@@ -919,8 +1188,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
|
|
919
1188
|
<>{control}</>
|
|
920
1189
|
</Tooltip>
|
|
921
1190
|
);
|
|
922
|
-
}
|
|
923
|
-
IconButton.displayName = "IconButton";
|
|
1191
|
+
}
|
|
924
1192
|
|
|
925
1193
|
export const FIELD_BASE =
|
|
926
1194
|
"block w-full rounded-md border border-[var(--border)] bg-[var(--bg-surface)] px-3 py-2 text-sm text-[var(--text-primary)] shadow-sm placeholder:text-[var(--text-placeholder)] focus:border-[var(--brand)] focus:ring-[var(--brand)] " +
|
|
@@ -94,7 +94,10 @@ export function FeedbackAttachmentField({
|
|
|
94
94
|
pasteFrom?: RefObject<HTMLElement | null>;
|
|
95
95
|
className?: string;
|
|
96
96
|
}) {
|
|
97
|
-
|
|
97
|
+
// The object URL is keyed to the file it was made for, so a stale one (from the
|
|
98
|
+
// previous file, or after `value` is cleared) is never shown — no reset needed.
|
|
99
|
+
const [previewFor, setPreviewFor] = useState<{ file: File; url: string } | null>(null);
|
|
100
|
+
const preview = value && previewFor?.file === value ? previewFor.url : null;
|
|
98
101
|
const [capturing, setCapturing] = useState(false);
|
|
99
102
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
100
103
|
// `file.size` from `<UiKitProvider labels>`, formatted in its locale — see FileDropzone.
|
|
@@ -103,12 +106,13 @@ export function FeedbackAttachmentField({
|
|
|
103
106
|
|
|
104
107
|
// Object-URL preview lifecycle (create on change, revoke on cleanup).
|
|
105
108
|
useEffect(() => {
|
|
106
|
-
if (!value)
|
|
107
|
-
setPreview(null);
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
109
|
+
if (!value) return;
|
|
110
110
|
const url = URL.createObjectURL(value);
|
|
111
|
-
|
|
111
|
+
// The URL is an external resource that must be created and revoked in step
|
|
112
|
+
// with the effect (a render-time `createObjectURL` would leak, and StrictMode's
|
|
113
|
+
// remount would revoke a memoised one out from under the <img>).
|
|
114
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect -- publishing an external resource's handle
|
|
115
|
+
setPreviewFor({ file: value, url });
|
|
112
116
|
return () => URL.revokeObjectURL(url);
|
|
113
117
|
}, [value]);
|
|
114
118
|
|
|
@@ -161,7 +165,6 @@ export function FeedbackAttachmentField({
|
|
|
161
165
|
};
|
|
162
166
|
target.addEventListener("paste", onPaste);
|
|
163
167
|
return () => target.removeEventListener("paste", onPaste);
|
|
164
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps -- takeImage reads `latest`, which is a ref
|
|
165
168
|
}, [documentPaste, pasteFrom]);
|
|
166
169
|
const listensElsewhere = documentPaste || pasteFrom !== undefined;
|
|
167
170
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useState } from "react";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
3
|
import { Button, Input, PHONE_QUERY, Select, Textarea } from "../components/ui";
|
|
4
4
|
import { Modal } from "../components/modal";
|
|
@@ -139,14 +139,17 @@ export function FeedbackDialog({
|
|
|
139
139
|
// prop's (so a caller's `attachmentAdd` still wins) plus the resolved heading.
|
|
140
140
|
const attachmentLabels = { ...labelsProp, attachment: labels.attachment };
|
|
141
141
|
|
|
142
|
-
// Reset the form whenever the dialog is (re)opened
|
|
143
|
-
|
|
142
|
+
// Reset the form whenever the dialog is (re)opened — during render, on the
|
|
143
|
+
// closed-to-open transition, so the old draft never paints for a frame.
|
|
144
|
+
const [wasOpen, setWasOpen] = useState(open);
|
|
145
|
+
if (open !== wasOpen) {
|
|
146
|
+
setWasOpen(open);
|
|
144
147
|
if (open) {
|
|
145
148
|
setTitle("");
|
|
146
149
|
setBody("");
|
|
147
150
|
setAttachment(null);
|
|
148
151
|
}
|
|
149
|
-
}
|
|
152
|
+
}
|
|
150
153
|
|
|
151
154
|
const canSubmit = !!title && !!body && !submitting;
|
|
152
155
|
const trySubmit = () => {
|
|
@@ -360,6 +360,10 @@ export function FeedbackStatusTransitions({
|
|
|
360
360
|
className?: string;
|
|
361
361
|
}) {
|
|
362
362
|
return (
|
|
363
|
+
// Not a control: it only stops clicks on the buttons inside from bubbling to
|
|
364
|
+
// the row. The buttons are the keyboard path, and Enter/Space on them fires
|
|
365
|
+
// their own click, which lands here the same way.
|
|
366
|
+
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- propagation guard around real buttons
|
|
363
367
|
<div
|
|
364
368
|
className={cn(variant === "icon" ? "flex items-center gap-0.5" : "flex flex-wrap gap-1.5", className)}
|
|
365
369
|
// A status control inside a clickable row must not also open the row.
|
|
@@ -511,6 +515,9 @@ export function FeedbackNoteEditor({
|
|
|
511
515
|
const root = useRef<HTMLDivElement>(null);
|
|
512
516
|
const submit = () => onSave(draft, file);
|
|
513
517
|
return (
|
|
518
|
+
// Not a control: a delegated Ctrl/Cmd+Enter shortcut for the textarea inside,
|
|
519
|
+
// which is what holds focus. The save button is the non-shortcut path.
|
|
520
|
+
// eslint-disable-next-line jsx-a11y/no-static-element-interactions -- delegated keyboard shortcut for the focused field
|
|
514
521
|
<div
|
|
515
522
|
ref={root}
|
|
516
523
|
className="space-y-2"
|