@eifi1/ui-kit 0.12.0 → 0.13.0
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 +17 -1
- package/dist/components/amount-input.js +46 -11
- 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 +211 -20
- 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 +6 -2
- package/dist/components/numpad-sheet.js +3 -2
- 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 +3 -1
- package/dist/lib/format.js.map +1 -1
- package/dist/rhf/fields.d.ts +57 -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 +97 -10
- 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 +453 -29
- 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 +6 -1
- 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 +46 -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 +29 -6
- package/src/rhf/fields.tsx +97 -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
|
@@ -60,6 +60,58 @@ export function pickLinkRenderer<P>(
|
|
|
60
60
|
return kitLink as unknown as (props: P) => ReactElement;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/** The plain primary click a page navigates on — not a middle click, not ⌘ / Ctrl /
|
|
64
|
+
* Shift / Alt (new tab, new window, download), and not one a handler already took. */
|
|
65
|
+
function isPlainNavigationClick(event: MouseEvent<HTMLAnchorElement>): boolean {
|
|
66
|
+
return (
|
|
67
|
+
!event.defaultPrevented &&
|
|
68
|
+
event.button === 0 &&
|
|
69
|
+
!event.metaKey &&
|
|
70
|
+
!event.ctrlKey &&
|
|
71
|
+
!event.shiftKey &&
|
|
72
|
+
!event.altKey
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The `onClick` of a PLAIN `<a>` whose kit link was asked to `replace` (keksdose F1).
|
|
78
|
+
*
|
|
79
|
+
* A router link replaces the history entry itself (it is handed `replace`, see
|
|
80
|
+
* {@link routerLinkNavigation}); a plain `<a>` has no attribute that says so, and the
|
|
81
|
+
* browser's own navigation always pushes. So a plain click is taken over — the caller's
|
|
82
|
+
* `onClick` runs first and may still `preventDefault` — and becomes
|
|
83
|
+
* `location.replace(href)`: the same document load, with Back skipping the one-shot URL.
|
|
84
|
+
* That is the case of `replace` together with `reloadDocument`, or an app with no
|
|
85
|
+
* `linkComponent`. A modified click (new tab) and a `target` other than `_self` are left
|
|
86
|
+
* to the browser: they do not navigate THIS history. Without `replace`, `onClick` comes
|
|
87
|
+
* back untouched.
|
|
88
|
+
* @internal
|
|
89
|
+
*/
|
|
90
|
+
export function replacingClick(
|
|
91
|
+
onClick: ((event: MouseEvent<HTMLAnchorElement>) => void) | undefined,
|
|
92
|
+
href: string,
|
|
93
|
+
replace: boolean | undefined,
|
|
94
|
+
target?: string,
|
|
95
|
+
): ((event: MouseEvent<HTMLAnchorElement>) => void) | undefined {
|
|
96
|
+
if (!replace || (target !== undefined && target !== "" && target !== "_self")) return onClick;
|
|
97
|
+
return (event) => {
|
|
98
|
+
onClick?.(event);
|
|
99
|
+
if (!isPlainNavigationClick(event)) return;
|
|
100
|
+
event.preventDefault();
|
|
101
|
+
window.location.replace(href);
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* What a ROUTER link is handed for `replace`: `{ replace: true }` when asked, else
|
|
107
|
+
* nothing at all — so a provider link that spreads its props onto a DOM `<a>` never
|
|
108
|
+
* receives an unknown attribute from a link that did not ask (see `KitLinkProps.replace`).
|
|
109
|
+
* @internal
|
|
110
|
+
*/
|
|
111
|
+
export function routerLinkNavigation(replace: boolean | undefined): { replace?: true } {
|
|
112
|
+
return replace ? { replace: true } : {};
|
|
113
|
+
}
|
|
114
|
+
|
|
63
115
|
/**
|
|
64
116
|
* Draws `props` through `render` as a component of its own, so a router link's hooks
|
|
65
117
|
* belong to it and not to the kit component calling it — for the kit components that
|
|
@@ -72,14 +124,33 @@ export function RenderedKitLink<P>({ render, props }: { render: (props: P) => Re
|
|
|
72
124
|
|
|
73
125
|
/* ── TextLink ─────────────────────────────────────────────────────────────── */
|
|
74
126
|
|
|
75
|
-
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
|
|
127
|
+
/**
|
|
128
|
+
* `brand` (default) for a link in running text, `muted` for footer and meta links
|
|
129
|
+
* that should not compete with the content, `danger` for a destructive one ("Leave
|
|
130
|
+
* group"), `inherit` for a link that takes its surroundings' colour.
|
|
131
|
+
*
|
|
132
|
+
* `primary` and `secondary` are the body and the secondary text colour, UNDERLINED
|
|
133
|
+
* (keksdose F3): a link that must read as part of the text around it — a name in a
|
|
134
|
+
* list row, a reference in a card's meta line — where `brand` repaints it in the accent
|
|
135
|
+
* and `muted` is a step too light to read as content. `secondary` darkens to the body
|
|
136
|
+
* colour under the pointer, as `muted` does; `primary` has nowhere darker to go, so its
|
|
137
|
+
* underline thickens instead.
|
|
138
|
+
*
|
|
139
|
+
* `warning` is the kit's `--warning` text colour, underlined (kastlan 46): a link inside
|
|
140
|
+
* a `<Tone tone="warning">` sentence or a warning banner, which `brand` would paint in a
|
|
141
|
+
* second hue and `inherit` would leave indistinguishable from the sentence. Like
|
|
142
|
+
* `primary`, it keeps its colour on hover and thickens the underline.
|
|
143
|
+
*
|
|
144
|
+
* The three colours that sit IN the text default to `underline="always"`: with the
|
|
145
|
+
* colour of their surroundings, the underline is the only thing that says "link"
|
|
146
|
+
* (WCAG 1.4.1). An explicit `underline` still wins.
|
|
147
|
+
*/
|
|
148
|
+
export type TextLinkTone = "brand" | "muted" | "danger" | "inherit" | "primary" | "secondary" | "warning";
|
|
79
149
|
|
|
80
|
-
/** `hover` (
|
|
81
|
-
*
|
|
82
|
-
*
|
|
150
|
+
/** `hover` (the default for `brand`, `muted`, `danger`, `inherit`): underlined under
|
|
151
|
+
* the pointer — the idiom all three apps draw. `always` (the default for `primary`,
|
|
152
|
+
* `secondary`, `warning`) for a link inside prose, where colour alone must not be the
|
|
153
|
+
* only mark (WCAG 1.4.1). `none` where the context already says "link" (a footer nav). */
|
|
83
154
|
export type TextLinkUnderline = "hover" | "always" | "none";
|
|
84
155
|
|
|
85
156
|
/** The `aria-current` token. `true` on {@link TextLinkProps.current} means `page`. */
|
|
@@ -98,6 +169,9 @@ export interface TextLinkRenderProps {
|
|
|
98
169
|
"aria-current"?: TextLinkCurrent;
|
|
99
170
|
id?: string;
|
|
100
171
|
title?: string;
|
|
172
|
+
/** Present, as `true`, only when the link was given `replace` — see
|
|
173
|
+
* `KitLinkProps.replace`. Map it to your router's replace; never onto a DOM `<a>`. */
|
|
174
|
+
replace?: boolean;
|
|
101
175
|
[key: `aria-${string}`]: string | boolean | number | undefined;
|
|
102
176
|
[key: `data-${string}`]: unknown;
|
|
103
177
|
}
|
|
@@ -108,8 +182,24 @@ export interface TextLinkProps
|
|
|
108
182
|
children: ReactNode;
|
|
109
183
|
/** Default `brand`. */
|
|
110
184
|
tone?: TextLinkTone;
|
|
111
|
-
/** Default `hover`. */
|
|
185
|
+
/** Default `always` for `primary`, `secondary` and `warning`, else `hover`. */
|
|
112
186
|
underline?: TextLinkUnderline;
|
|
187
|
+
/**
|
|
188
|
+
* Navigate by replacing the current history entry, so Back does not return to this
|
|
189
|
+
* URL — keksdose F1, a one-shot URL (a confirmation landing, a `?done=1` hop). Handed
|
|
190
|
+
* to the router link as `replace` (see `KitLinkProps.replace` for mapping it); on a
|
|
191
|
+
* plain `<a>` (no `linkComponent`, or `reloadDocument`) a plain click becomes
|
|
192
|
+
* `location.replace(href)`. Ignored on an `external` link, which opens a new tab.
|
|
193
|
+
*/
|
|
194
|
+
replace?: boolean;
|
|
195
|
+
/**
|
|
196
|
+
* A plain `<a>` for an in-app `href`: the browser loads the whole document instead of
|
|
197
|
+
* the router swapping the view — keksdose F1, a route served outside the SPA (a file
|
|
198
|
+
* download, a server-rendered page, a logout that must drop every in-memory cache).
|
|
199
|
+
* Wins over `renderLink` and the provider's `linkComponent`: it is the caller saying
|
|
200
|
+
* "not the router" for this one link.
|
|
201
|
+
*/
|
|
202
|
+
reloadDocument?: boolean;
|
|
113
203
|
/**
|
|
114
204
|
* The link leaves the app: a plain `<a target="_blank" rel="noopener noreferrer">`
|
|
115
205
|
* (never the router's), an external-link mark after the text (mirrored in RTL), and
|
|
@@ -141,8 +231,15 @@ const TONE: Record<TextLinkTone, string> = {
|
|
|
141
231
|
muted: "text-[var(--text-muted)] hover:text-[var(--text-primary)]",
|
|
142
232
|
danger: "text-[var(--danger)] hover:text-[var(--danger-hover)]",
|
|
143
233
|
inherit: "",
|
|
234
|
+
primary: "text-[var(--text-primary)] hover:decoration-2",
|
|
235
|
+
secondary: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]",
|
|
236
|
+
warning: "text-[var(--warning)] hover:decoration-2",
|
|
144
237
|
};
|
|
145
238
|
|
|
239
|
+
/** The tones whose colour is their surroundings' — underlined by default; see
|
|
240
|
+
* {@link TextLinkTone}. */
|
|
241
|
+
const UNDERLINED_BY_DEFAULT = new Set<TextLinkTone>(["primary", "secondary", "warning"]);
|
|
242
|
+
|
|
146
243
|
const UNDERLINE: Record<TextLinkUnderline, string> = {
|
|
147
244
|
hover: "no-underline hover:underline",
|
|
148
245
|
always: "underline",
|
|
@@ -168,8 +265,10 @@ export function TextLink({
|
|
|
168
265
|
href,
|
|
169
266
|
children,
|
|
170
267
|
tone = "brand",
|
|
171
|
-
underline
|
|
268
|
+
underline: underlineProp,
|
|
172
269
|
external = false,
|
|
270
|
+
replace,
|
|
271
|
+
reloadDocument = false,
|
|
173
272
|
stopPropagation = false,
|
|
174
273
|
current,
|
|
175
274
|
icon: Icon,
|
|
@@ -182,6 +281,7 @@ export function TextLink({
|
|
|
182
281
|
}: TextLinkProps) {
|
|
183
282
|
const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
|
|
184
283
|
const kitLink = useKitLink();
|
|
284
|
+
const underline = underlineProp ?? (UNDERLINED_BY_DEFAULT.has(tone) ? "always" : "hover");
|
|
185
285
|
const ariaCurrent: TextLinkCurrent | undefined =
|
|
186
286
|
current === true ? "page" : current === false ? undefined : current;
|
|
187
287
|
|
|
@@ -208,18 +308,19 @@ export function TextLink({
|
|
|
208
308
|
</>
|
|
209
309
|
);
|
|
210
310
|
|
|
311
|
+
const handleClick: TextLinkRenderProps["onClick"] = stopPropagation
|
|
312
|
+
? (event) => {
|
|
313
|
+
event.stopPropagation();
|
|
314
|
+
onClick?.(event);
|
|
315
|
+
}
|
|
316
|
+
: onClick;
|
|
211
317
|
const props: TextLinkRenderProps = {
|
|
212
318
|
...(rest as Partial<TextLinkRenderProps>),
|
|
213
319
|
ref,
|
|
214
320
|
href,
|
|
215
321
|
className: look,
|
|
216
322
|
"aria-current": ariaCurrent,
|
|
217
|
-
onClick:
|
|
218
|
-
? (event) => {
|
|
219
|
-
event.stopPropagation();
|
|
220
|
-
onClick?.(event);
|
|
221
|
-
}
|
|
222
|
-
: onClick,
|
|
323
|
+
onClick: handleClick,
|
|
223
324
|
onKeyDown: stopPropagation
|
|
224
325
|
? (event) => {
|
|
225
326
|
if (event.key === "Enter") event.stopPropagation();
|
|
@@ -229,11 +330,18 @@ export function TextLink({
|
|
|
229
330
|
children: body,
|
|
230
331
|
};
|
|
231
332
|
|
|
232
|
-
const render = external ? undefined : pickLinkRenderer(renderLink, kitLink, href);
|
|
233
|
-
if (render) return <RenderedTextLink render={render} {...props} />;
|
|
333
|
+
const render = external || reloadDocument ? undefined : pickLinkRenderer(renderLink, kitLink, href);
|
|
334
|
+
if (render) return <RenderedTextLink render={render} {...props} {...routerLinkNavigation(replace)} />;
|
|
335
|
+
// A plain `<a>` never gets `replace` as an attribute; asked for, it takes the click.
|
|
336
|
+
const plainClick = external ? handleClick : replacingClick(handleClick, href, replace, rest.target);
|
|
234
337
|
const { children: content, ...anchor } = props;
|
|
235
338
|
return (
|
|
236
|
-
<a
|
|
339
|
+
<a
|
|
340
|
+
{...anchor}
|
|
341
|
+
href={href}
|
|
342
|
+
onClick={plainClick}
|
|
343
|
+
{...(external ? { target: "_blank", rel: "noopener noreferrer" } : null)}
|
|
344
|
+
>
|
|
237
345
|
{content}
|
|
238
346
|
</a>
|
|
239
347
|
);
|
package/src/components/text.tsx
CHANGED
|
@@ -16,6 +16,14 @@ import { cn } from "../lib/cn";
|
|
|
16
16
|
* (support-thread:153/223) at `text-[11px]` by hand — 10px read as a footnote under a
|
|
17
17
|
* figure that size, 12px competed with it. Named `md` rather than slotted in order
|
|
18
18
|
* because `sm` was already the default and renaming it would move every caller.
|
|
19
|
+
*
|
|
20
|
+
* `md` is also the heading over a CHART COLUMN (lenkbank P9): the small label centred
|
|
21
|
+
* over each of a pair of facing charts, or over each column of a small-multiples grid.
|
|
22
|
+
* There the label names a plot, not a section of the page, so `sm` (12px, the page's
|
|
23
|
+
* section heading) out-ranks the card title above it, and `xs` (10px, a legend's
|
|
24
|
+
* title) sinks below the axis ticks it sits beside. Rule of thumb: `xs` inside a
|
|
25
|
+
* control (legend, menu), `md` over a figure or a plot, `sm` over a block of page
|
|
26
|
+
* content.
|
|
19
27
|
*/
|
|
20
28
|
export type SectionLabelSize = "xs" | "md" | "sm";
|
|
21
29
|
|
|
@@ -34,6 +42,31 @@ export const SECTION_LABEL_CLASS: Record<SectionLabelSize, string> = {
|
|
|
34
42
|
sm: "text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]",
|
|
35
43
|
};
|
|
36
44
|
|
|
45
|
+
/**
|
|
46
|
+
* `plain` (default): the label alone, as above.
|
|
47
|
+
*
|
|
48
|
+
* `band` (0.13, keksdose F5): the label on a full-width `surface-2` bar with a bottom
|
|
49
|
+
* border — the day-group header of keksdose's mobile transaction list
|
|
50
|
+
* (mobile-transaction-list.tsx:171), which separates one day's rows from the next.
|
|
51
|
+
* Its type is kept as keksdose drew it — `font-medium` in the SECONDARY text colour,
|
|
52
|
+
* not the plain label's semibold muted — so adopting the kit does not restyle a live
|
|
53
|
+
* list: the bar already sets the label apart, and needs no extra weight. The padding (`px-4 py-2`) lines the text up with a
|
|
54
|
+
* `ListItem density="comfortable"` row below it; override it with `className` for a
|
|
55
|
+
* list at another density. A sticky header is the caller's: add `sticky top-0`.
|
|
56
|
+
*/
|
|
57
|
+
export type SectionLabelVariant = "plain" | "band";
|
|
58
|
+
|
|
59
|
+
/** The band's own type and box, per size — spelled out rather than merged over
|
|
60
|
+
* {@link SECTION_LABEL_CLASS}, so the result does not hang on tailwind-merge telling
|
|
61
|
+
* a font-size `text-[…]` from a colour `text-[var(…)]`. */
|
|
62
|
+
const SECTION_LABEL_BAND_CLASS: Record<SectionLabelSize, string> = {
|
|
63
|
+
xs: "text-[10px]",
|
|
64
|
+
md: "text-[11px]",
|
|
65
|
+
sm: "text-xs",
|
|
66
|
+
};
|
|
67
|
+
const BAND =
|
|
68
|
+
"block border-b border-[var(--border)] bg-[var(--bg-surface-2)] px-4 py-2 font-medium uppercase tracking-wide text-[var(--text-secondary)]";
|
|
69
|
+
|
|
37
70
|
type SectionLabelElement = "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "span" | "div" | "legend";
|
|
38
71
|
|
|
39
72
|
export interface SectionLabelProps extends ComponentPropsWithoutRef<"h3"> {
|
|
@@ -45,6 +78,9 @@ export interface SectionLabelProps extends ComponentPropsWithoutRef<"h3"> {
|
|
|
45
78
|
*/
|
|
46
79
|
as?: SectionLabelElement;
|
|
47
80
|
size?: SectionLabelSize;
|
|
81
|
+
/** `band` draws the label on a `surface-2` bar with a bottom border — a list's group
|
|
82
|
+
* header. See {@link SectionLabelVariant}. */
|
|
83
|
+
variant?: SectionLabelVariant;
|
|
48
84
|
children: ReactNode;
|
|
49
85
|
}
|
|
50
86
|
|
|
@@ -55,10 +91,18 @@ export interface SectionLabelProps extends ComponentPropsWithoutRef<"h3"> {
|
|
|
55
91
|
* 10px version on a menu row. One component, the token colour, and the level chosen
|
|
56
92
|
* where the outline is known.
|
|
57
93
|
*/
|
|
58
|
-
export function SectionLabel({
|
|
94
|
+
export function SectionLabel({
|
|
95
|
+
as = "h3",
|
|
96
|
+
size = "sm",
|
|
97
|
+
variant = "plain",
|
|
98
|
+
className,
|
|
99
|
+
children,
|
|
100
|
+
...rest
|
|
101
|
+
}: SectionLabelProps) {
|
|
59
102
|
const Tag = as as ElementType;
|
|
103
|
+
const type = variant === "band" ? cn(SECTION_LABEL_BAND_CLASS[size], BAND) : SECTION_LABEL_CLASS[size];
|
|
60
104
|
return (
|
|
61
|
-
<Tag {...rest} className={cn(
|
|
105
|
+
<Tag {...rest} className={cn(type, className)}>
|
|
62
106
|
{children}
|
|
63
107
|
</Tag>
|
|
64
108
|
);
|
|
@@ -104,6 +104,25 @@ type ToggleGroupCaption<V> = ReactNode | ((value: V) => ReactNode);
|
|
|
104
104
|
/** The group as it has always been: one option is always the answer. */
|
|
105
105
|
export interface ToggleGroupRequiredProps<T extends string> extends ToggleGroupBaseProps<T> {
|
|
106
106
|
allowEmpty?: false;
|
|
107
|
+
/**
|
|
108
|
+
* What the options ARE to a screen reader, separately from whether the group can be
|
|
109
|
+
* emptied (kastlan feedback #47).
|
|
110
|
+
*
|
|
111
|
+
* - `"radio"` (default) — a `radiogroup` of radios: one Tab stop, arrow keys MOVE the
|
|
112
|
+
* choice. Right for a choice between answers ("Monthly / Yearly").
|
|
113
|
+
* - `"pressed"` — a `group` of toggle buttons (`aria-pressed`), each its own Tab stop,
|
|
114
|
+
* Space/Enter to press; still one option always pressed. Right for a row that reads
|
|
115
|
+
* as a set of switches — a view mode, a toolbar-like filter — where arrows moving the
|
|
116
|
+
* selection on focus would be a surprise.
|
|
117
|
+
*
|
|
118
|
+
* Before this the only way to `aria-pressed` was `allowEmpty`, which also let a second
|
|
119
|
+
* press clear the choice — so kastlan took the clearable shape and threw the `null`
|
|
120
|
+
* away in `onChange` (`(v) => v && setMode(v)`), a group announcing that a press would
|
|
121
|
+
* unpress what it would not. Here a press on the pressed option re-sends its value, as
|
|
122
|
+
* the radio shape does, and nothing is ever unpressed. A clearable group
|
|
123
|
+
* (`allowEmpty`) is always `"pressed"`: a radio cannot be unchecked by activating it.
|
|
124
|
+
*/
|
|
125
|
+
semantics?: "radio" | "pressed";
|
|
107
126
|
value: T;
|
|
108
127
|
onChange: (value: T) => void;
|
|
109
128
|
/** See {@link ToggleGroupCaption}. */
|
|
@@ -122,6 +141,9 @@ export interface ToggleGroupRequiredProps<T extends string> extends ToggleGroupB
|
|
|
122
141
|
*/
|
|
123
142
|
export interface ToggleGroupClearableProps<T extends string> extends ToggleGroupBaseProps<T> {
|
|
124
143
|
allowEmpty: true;
|
|
144
|
+
/** Always toggle buttons — see {@link ToggleGroupRequiredProps.semantics}. Accepted so
|
|
145
|
+
* a wrapper can forward one `semantics` to either shape. */
|
|
146
|
+
semantics?: "pressed";
|
|
125
147
|
value: T | null;
|
|
126
148
|
onChange: (value: T | null) => void;
|
|
127
149
|
/** See {@link ToggleGroupCaption}. `null` while nothing is chosen. */
|
|
@@ -171,7 +193,7 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
|
|
|
171
193
|
const hasError = labelled && error !== undefined && error !== null && error !== false && error !== "";
|
|
172
194
|
// Taken off the rest so neither reaches the DOM; `props` keeps them paired, which is
|
|
173
195
|
// what lets the `onChange` below be called with `null` only in the mode that allows it.
|
|
174
|
-
const { allowEmpty: _allowEmpty, onChange: _onChange, caption, ...rest } = restWithMode;
|
|
196
|
+
const { allowEmpty: _allowEmpty, onChange: _onChange, caption, semantics: _semantics, ...rest } = restWithMode;
|
|
175
197
|
// Invalid from outside too: a `Field` hands the bare group `aria-invalid`, and the
|
|
176
198
|
// border has to say what the attribute says.
|
|
177
199
|
const outsideInvalid = rest["aria-invalid"] === true || rest["aria-invalid"] === "true";
|
|
@@ -183,11 +205,14 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
|
|
|
183
205
|
if (props.allowEmpty) props.onChange(next === value ? null : next);
|
|
184
206
|
else props.onChange(next);
|
|
185
207
|
};
|
|
186
|
-
|
|
208
|
+
// `pressed`: toggle buttons. Every clearable group is; a required one is when it asks
|
|
209
|
+
// (kastlan #47) — the role, and not whether a second press clears, is what this flag
|
|
210
|
+
// decides from here on, so `choose` above still keys off `allowEmpty` alone.
|
|
211
|
+
const pressed = props.allowEmpty === true || props.semantics === "pressed";
|
|
187
212
|
// A radio group is ONE tab stop (the checked radio, else the first) and arrows move
|
|
188
213
|
// the choice — the pattern `role="radiogroup"` promises a screen-reader user. Before
|
|
189
|
-
// 0.7.0 each segment was its own tab stop with no arrow keys. The
|
|
190
|
-
// row of
|
|
214
|
+
// 0.7.0 each segment was its own tab stop with no arrow keys. The toggle-button shape
|
|
215
|
+
// is a row of buttons, where separate tab stops are the pattern.
|
|
191
216
|
const tabStop = options.some((o) => o.value === value) ? value : options[0]?.value;
|
|
192
217
|
const onRadioKey = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
|
193
218
|
const last = options.length - 1;
|
|
@@ -218,7 +243,7 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
|
|
|
218
243
|
// radiogroup role or the disabled state by accident. `className` is destructured
|
|
219
244
|
// out entirely and merged through `cn`, so it is never in here.
|
|
220
245
|
{...rest}
|
|
221
|
-
role={
|
|
246
|
+
role={pressed ? "group" : "radiogroup"}
|
|
222
247
|
// The DOM spelling wins; `ariaLabel` is the fallback for the call sites that
|
|
223
248
|
// have not moved yet.
|
|
224
249
|
aria-label={ariaLabelAttr ?? ariaLabel}
|
|
@@ -263,12 +288,12 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
|
|
|
263
288
|
<button
|
|
264
289
|
key={opt.value}
|
|
265
290
|
type="button"
|
|
266
|
-
role={
|
|
267
|
-
aria-checked={
|
|
268
|
-
aria-pressed={
|
|
291
|
+
role={pressed ? undefined : "radio"}
|
|
292
|
+
aria-checked={pressed ? undefined : active}
|
|
293
|
+
aria-pressed={pressed ? active : undefined}
|
|
269
294
|
disabled={disabled}
|
|
270
|
-
tabIndex={
|
|
271
|
-
onKeyDown={
|
|
295
|
+
tabIndex={pressed ? undefined : opt.value === tabStop ? 0 : -1}
|
|
296
|
+
onKeyDown={pressed ? undefined : (e) => onRadioKey(e, index)}
|
|
272
297
|
onClick={() => choose(opt.value)}
|
|
273
298
|
className={cn(
|
|
274
299
|
// `truncate` (which carries whitespace-nowrap) rather than letting a
|
|
@@ -83,6 +83,23 @@ export interface LegendEntry {
|
|
|
83
83
|
* "today"). Decorative: it is wrapped `aria-hidden`, and the label is the name.
|
|
84
84
|
*/
|
|
85
85
|
icon?: ReactNode;
|
|
86
|
+
/**
|
|
87
|
+
* `"end"`: push this entry to the legend's inline END — the far right in LTR, the far
|
|
88
|
+
* left in RTL — with everything before it kept at the start. kastlan's lease gantt
|
|
89
|
+
* (feedback #49) keys its bars at the start and its "today" hairline at the end, over
|
|
90
|
+
* the time axis whose end "today" is nearest, and wrote it as
|
|
91
|
+
* `className="[&>li:last-child]:ms-auto"` — a selector that breaks the moment the
|
|
92
|
+
* entries are reordered and that reaches into the legend's markup.
|
|
93
|
+
*
|
|
94
|
+
* An auto inline-start margin, so it is logical (RTL mirrors it) and it takes only the
|
|
95
|
+
* row's LEFTOVER space: on a narrow screen where the row wraps, the entry wraps with it
|
|
96
|
+
* and sits at the end of its own line rather than overflowing. Give it to the LAST
|
|
97
|
+
* entry — auto margins push everything after them too, so an end entry in the middle
|
|
98
|
+
* drags the rest over with it (which is also how to end-align a GROUP: mark its first).
|
|
99
|
+
* In a vertical legend it sits at the column's inline end. Honoured by both
|
|
100
|
+
* {@link StaticLegend} and {@link ToggleLegend}, which share this entry type.
|
|
101
|
+
*/
|
|
102
|
+
align?: "start" | "end";
|
|
86
103
|
}
|
|
87
104
|
|
|
88
105
|
export interface ToggleLegendProps {
|
|
@@ -159,6 +176,7 @@ export function ToggleLegend({
|
|
|
159
176
|
"flex items-center gap-1.5 rounded text-start text-[11px] text-[var(--text-secondary)] transition-opacity hover:opacity-80",
|
|
160
177
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]",
|
|
161
178
|
off && "opacity-35",
|
|
179
|
+
entry.align === "end" && "ms-auto",
|
|
162
180
|
)}
|
|
163
181
|
>
|
|
164
182
|
<LegendMark entry={entry} off={off} />
|
|
@@ -219,7 +237,10 @@ export function StaticLegend({
|
|
|
219
237
|
{entries.map((entry) => (
|
|
220
238
|
<li
|
|
221
239
|
key={entry.key}
|
|
222
|
-
className=
|
|
240
|
+
className={cn(
|
|
241
|
+
"flex items-center gap-1.5 text-[11px] text-[var(--text-secondary)]",
|
|
242
|
+
entry.align === "end" && "ms-auto",
|
|
243
|
+
)}
|
|
223
244
|
>
|
|
224
245
|
<LegendMark entry={entry} off={false} />
|
|
225
246
|
{entry.label}
|
|
@@ -87,6 +87,19 @@ export interface TooltipProps extends ComponentPropsWithoutRef<"span"> {
|
|
|
87
87
|
* what they were before the default existed.
|
|
88
88
|
*/
|
|
89
89
|
portal?: boolean;
|
|
90
|
+
/**
|
|
91
|
+
* Mount the IN-PLACE bubble only while it is up (hovered or focused, not dismissed),
|
|
92
|
+
* instead of always. It still sits next to its trigger and is still placed by CSS
|
|
93
|
+
* alone; what changes is that, while closed, it is not in the DOM — so it is not in
|
|
94
|
+
* `getAllByRole("tooltip")`, not in an ancestor's `textContent` and not in an
|
|
95
|
+
* ancestor's accessible name. The trigger is described (`aria-describedby`) while
|
|
96
|
+
* the bubble is up, exactly as a portalled one is. See "Lazy in place" on
|
|
97
|
+
* {@link Tooltip} for why this exists (keksdose F6) and why it is not the default.
|
|
98
|
+
*
|
|
99
|
+
* Has no effect on a bubble that is portalled (`portal`, or auto-portalled inside a
|
|
100
|
+
* clipping container) — that one is already mounted only while up.
|
|
101
|
+
*/
|
|
102
|
+
lazy?: boolean;
|
|
90
103
|
/** Tag the bubble `data-private`, for a label that repeats the user's own data. */
|
|
91
104
|
redact?: boolean;
|
|
92
105
|
children: ReactNode;
|
|
@@ -94,7 +107,7 @@ export interface TooltipProps extends ComponentPropsWithoutRef<"span"> {
|
|
|
94
107
|
|
|
95
108
|
/** What each variant below takes: the resolved `side`, and every span attribute the
|
|
96
109
|
* caller handed {@link Tooltip}, forwarded to that variant's own wrapper. */
|
|
97
|
-
type TooltipVariantProps = Omit<TooltipProps, "side" | "portal"> & { side: TooltipSide };
|
|
110
|
+
type TooltipVariantProps = Omit<TooltipProps, "side" | "portal" | "lazy"> & { side: TooltipSide };
|
|
98
111
|
|
|
99
112
|
/**
|
|
100
113
|
* Hover/focus label for a control.
|
|
@@ -170,12 +183,50 @@ type TooltipVariantProps = Omit<TooltipProps, "side" | "portal"> & { side: Toolt
|
|
|
170
183
|
* Why not simply portal everything? Because the in-place bubble is the one existing
|
|
171
184
|
* app tests rely on (it is in the DOM without a hover), and because it follows its
|
|
172
185
|
* trigger through a scroll or an animation for free — the portalled one re-measures.
|
|
186
|
+
*
|
|
187
|
+
* ⚠️ **Lazy in place: `lazy` keeps the bubble next to its trigger but out of the DOM
|
|
188
|
+
* until it is up (keksdose F6).** `data-clips` cured the doubled text for tooltips
|
|
189
|
+
* inside a table; it did nothing for the ones outside any clipping container, where the
|
|
190
|
+
* always-mounted bubble is still a real `role="tooltip"` node full of text. About seven
|
|
191
|
+
* keksdose sites — column-header tooltips, a direction toggle, an fx-estimate row, a
|
|
192
|
+
* FlagBadge, a toast inside a `<button>` — still pinned `portal` for that alone: their
|
|
193
|
+
* tests found two tooltips with `getAllByRole("tooltip")`, read the label twice in a
|
|
194
|
+
* `textContent`, or got a button whose accessible name had the bubble's sentence glued
|
|
195
|
+
* on. `portal` fixed the test and cost the browser the measured, re-positioning bubble
|
|
196
|
+
* for no reason. `lazy` is the in-place bubble with the portalled one's lifetime: it is
|
|
197
|
+
* rendered only while hovered or focused (and not dismissed), in the same slot, with the
|
|
198
|
+
* same classes, so it looks and sits exactly as the default one does when it is up.
|
|
199
|
+
*
|
|
200
|
+
* Why unmount rather than hide? Hiding was the other option — keep the bubble mounted
|
|
201
|
+
* with `hidden` (or `aria-hidden` plus `display: none` until `:hover` / `:focus-within`)
|
|
202
|
+
* and let `aria-describedby` go on pointing at it, which the accname algorithm allows:
|
|
203
|
+
* a node referenced directly by `aria-describedby` contributes its text even while
|
|
204
|
+
* hidden. That would take the bubble out of the role queries and out of an ancestor's
|
|
205
|
+
* accessible name — but NOT out of `textContent`, which is plain DOM and counts hidden
|
|
206
|
+
* text too, and `textContent` is one of the three things F6 lists. Only a bubble that
|
|
207
|
+
* is not there solves all three. The cost is the portalled variant's: the trigger is
|
|
208
|
+
* described while the bubble is up rather than always. Focusing the trigger IS what
|
|
209
|
+
* puts it up, and React commits the `aria-describedby` in the same task as that focus
|
|
210
|
+
* event, so a screen reader landing on the control still hears it; what goes is the description
|
|
211
|
+
* of a trigger that is read in browse mode without ever being focused.
|
|
212
|
+
*
|
|
213
|
+
* Escape still dismisses it — here by unmounting it, which takes it off the screen and
|
|
214
|
+
* out of the accessibility tree at once — and the next hover or focus brings it back.
|
|
215
|
+
* There is no fade to lose: neither in-place bubble has ever had a transition, only the
|
|
216
|
+
* `opacity` switch, so appearing on a state change looks the same as appearing on
|
|
217
|
+
* `:hover`.
|
|
218
|
+
*
|
|
219
|
+
* Why opt-in rather than the new default? Because the always-mounted bubble is the one
|
|
220
|
+
* existing app tests find without a hover (see above): flipping it would turn every
|
|
221
|
+
* `getByRole("tooltip")` written against 0.12 into a failure in all three apps at once.
|
|
222
|
+
* Reach for `lazy` wherever a `portal` was pinned only to keep a test's DOM clean.
|
|
173
223
|
*/
|
|
174
224
|
export function Tooltip({
|
|
175
225
|
label,
|
|
176
226
|
side = "top",
|
|
177
227
|
className,
|
|
178
228
|
portal,
|
|
229
|
+
lazy = false,
|
|
179
230
|
redact = false,
|
|
180
231
|
children,
|
|
181
232
|
...rest
|
|
@@ -208,6 +259,7 @@ export function Tooltip({
|
|
|
208
259
|
className={className}
|
|
209
260
|
redact={redact}
|
|
210
261
|
detect={portal === undefined}
|
|
262
|
+
lazy={lazy}
|
|
211
263
|
{...rest}
|
|
212
264
|
>
|
|
213
265
|
{children}
|
|
@@ -235,9 +287,10 @@ function InPlaceTooltip({
|
|
|
235
287
|
className,
|
|
236
288
|
redact,
|
|
237
289
|
detect,
|
|
290
|
+
lazy,
|
|
238
291
|
children,
|
|
239
292
|
...rest
|
|
240
|
-
}: TooltipVariantProps & { detect: boolean }) {
|
|
293
|
+
}: TooltipVariantProps & { detect: boolean; lazy: boolean }) {
|
|
241
294
|
const id = useId();
|
|
242
295
|
const triggerRef = useRef<HTMLSpanElement | null>(null);
|
|
243
296
|
const [clipped, setClipped] = useState(false);
|
|
@@ -263,6 +316,9 @@ function InPlaceTooltip({
|
|
|
263
316
|
setClipped(hasClippingAncestor(el));
|
|
264
317
|
};
|
|
265
318
|
return (
|
|
319
|
+
/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- the handlers track
|
|
320
|
+
whether the bubble is up and activate nothing; the caller's child is the
|
|
321
|
+
interactive element, keeps its own handlers, and its focus shows the bubble too. */
|
|
266
322
|
<span
|
|
267
323
|
// `...rest` first: the four handlers below are what decides whether a bubble is
|
|
268
324
|
// up, and a caller passing an `onFocus` of its own must not replace them.
|
|
@@ -272,10 +328,6 @@ function InPlaceTooltip({
|
|
|
272
328
|
// These four track WHETHER A BUBBLE IS UP. They activate nothing — the only thing
|
|
273
329
|
// here that can be activated is the caller's child, which keeps every handler it
|
|
274
330
|
// arrived with — so this wrapper needs no role and no key handling of its own.
|
|
275
|
-
// `jsx-a11y/no-static-element-interactions` warns about it all the same, as it
|
|
276
|
-
// already does about the portal variant's identical trigger below; both are left
|
|
277
|
-
// visible rather than silenced, because a rule this package ratchets should be
|
|
278
|
-
// argued with in the backlog and not in a disable comment.
|
|
279
331
|
onMouseEnter={(e) => {
|
|
280
332
|
setHovered(true);
|
|
281
333
|
arm(e.currentTarget);
|
|
@@ -287,12 +339,28 @@ function InPlaceTooltip({
|
|
|
287
339
|
}}
|
|
288
340
|
onBlur={() => setFocused(false)}
|
|
289
341
|
>
|
|
290
|
-
{/* In place the bubble is always there to point at; portalled, only while up. */}
|
|
291
|
-
{describedBy(children, clipped ? (open ? id : undefined) : dismissed ? undefined : id)}
|
|
342
|
+
{/* In place the bubble is always there to point at; portalled or lazy, only while up. */}
|
|
343
|
+
{describedBy(children, clipped || lazy ? (open ? id : undefined) : dismissed ? undefined : id)}
|
|
292
344
|
{clipped ? (
|
|
293
345
|
open && (
|
|
294
346
|
<PortalBubble triggerRef={triggerRef} id={id} label={label} side={side} dir={dir} redact={redact} />
|
|
295
347
|
)
|
|
348
|
+
) : lazy ? (
|
|
349
|
+
// keksdose F6: the in-place slot and classes, the portalled lifetime. Mounted only
|
|
350
|
+
// while up, so it is visible whenever it exists — `opacity-100` outright rather
|
|
351
|
+
// than the `group-hover` switch, which would also be right but would make the
|
|
352
|
+
// bubble's visibility depend on two sources (the state that mounted it and the
|
|
353
|
+
// CSS that shows it) that can disagree for a frame after Escape.
|
|
354
|
+
open && (
|
|
355
|
+
<span
|
|
356
|
+
id={id}
|
|
357
|
+
role="tooltip"
|
|
358
|
+
data-private={redact ? "" : undefined}
|
|
359
|
+
className={cn(TOOLTIP_SURFACE, "pointer-events-none absolute z-50 opacity-100", sidePositionClass[side])}
|
|
360
|
+
>
|
|
361
|
+
{label}
|
|
362
|
+
</span>
|
|
363
|
+
)
|
|
296
364
|
) : (
|
|
297
365
|
<span
|
|
298
366
|
id={id}
|
|
@@ -506,6 +574,9 @@ function PortalTooltip({
|
|
|
506
574
|
|
|
507
575
|
return (
|
|
508
576
|
<>
|
|
577
|
+
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- as in
|
|
578
|
+
`InPlaceTooltip`: the handlers only show and hide the bubble; the caller's child
|
|
579
|
+
is the interactive element, and focusing it shows the bubble. */}
|
|
509
580
|
<span
|
|
510
581
|
// As in `InPlaceTooltip`: the caller's attributes first, the four handlers that
|
|
511
582
|
// run this component after them. The BUBBLE is deliberately not given them — it
|