@eifi1/ui-kit 0.6.1 → 0.7.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 +69 -33
- package/dist/chart.d.ts +1 -0
- package/dist/components/account-settings.js +3 -2
- package/dist/components/account-settings.js.map +1 -1
- package/dist/components/amount-input.d.ts +5 -0
- package/dist/components/amount-input.js +6 -4
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/autocomplete.d.ts +33 -6
- package/dist/components/autocomplete.js +62 -16
- package/dist/components/autocomplete.js.map +1 -1
- package/dist/components/calculator.d.ts +5 -0
- package/dist/components/calculator.js +2 -1
- package/dist/components/calculator.js.map +1 -1
- package/dist/components/chart-zoom.js +2 -1
- package/dist/components/chart-zoom.js.map +1 -1
- package/dist/components/chart.d.ts +33 -2
- package/dist/components/chart.js +20 -8
- package/dist/components/chart.js.map +1 -1
- package/dist/components/chip.d.ts +34 -14
- package/dist/components/chip.js +63 -28
- package/dist/components/chip.js.map +1 -1
- package/dist/components/choice-card.d.ts +18 -1
- package/dist/components/choice-card.js +21 -8
- package/dist/components/choice-card.js.map +1 -1
- package/dist/components/combobox-core.d.ts +61 -2
- package/dist/components/combobox-core.js +68 -17
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.d.ts +11 -7
- package/dist/components/combobox.js +51 -28
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/currency-select.js +3 -3
- package/dist/components/currency-select.js.map +1 -1
- package/dist/components/danger-confirm.d.ts +30 -6
- package/dist/components/danger-confirm.js +51 -32
- package/dist/components/danger-confirm.js.map +1 -1
- package/dist/components/data-table-filter-popover.d.ts +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table-pagination.d.ts +6 -3
- package/dist/components/data-table-pagination.js +6 -5
- package/dist/components/data-table-pagination.js.map +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +332 -248
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.js +17 -7
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/dialog-frame.d.ts +28 -3
- package/dist/components/dialog-frame.js +47 -11
- package/dist/components/dialog-frame.js.map +1 -1
- package/dist/components/disclosure.d.ts +22 -2
- package/dist/components/disclosure.js +41 -8
- package/dist/components/disclosure.js.map +1 -1
- package/dist/components/dropdown.d.ts +7 -3
- package/dist/components/dropdown.js +6 -2
- package/dist/components/dropdown.js.map +1 -1
- package/dist/components/entity-combobox.d.ts +17 -8
- package/dist/components/entity-combobox.js +7 -5
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/file-button.d.ts +38 -5
- package/dist/components/file-button.js +23 -2
- package/dist/components/file-button.js.map +1 -1
- package/dist/components/file-dropzone.d.ts +11 -5
- package/dist/components/file-dropzone.js +14 -19
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/grouped-picker.js +2 -2
- package/dist/components/grouped-picker.js.map +1 -1
- package/dist/components/hover-menu.d.ts +8 -3
- package/dist/components/hover-menu.js +88 -4
- package/dist/components/hover-menu.js.map +1 -1
- package/dist/components/icon-picker.d.ts +10 -1
- package/dist/components/icon-picker.js +5 -1
- package/dist/components/icon-picker.js.map +1 -1
- package/dist/components/measured-grid.d.ts +165 -0
- package/dist/components/measured-grid.js +559 -0
- package/dist/components/measured-grid.js.map +1 -0
- package/dist/components/mini-calendar.js +7 -8
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/month-picker.js +13 -9
- package/dist/components/month-picker.js.map +1 -1
- package/dist/components/multi-entity-combobox.js +2 -2
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/multi-select.d.ts +9 -1
- package/dist/components/multi-select.js +22 -8
- package/dist/components/multi-select.js.map +1 -1
- package/dist/components/number-field.d.ts +5 -0
- package/dist/components/number-input.d.ts +5 -0
- package/dist/components/number-input.js +3 -3
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +5 -0
- package/dist/components/numpad-sheet.js +3 -3
- package/dist/components/numpad-sheet.js.map +1 -1
- package/dist/components/picker-sheet.js +1 -1
- package/dist/components/picker-sheet.js.map +1 -1
- package/dist/components/popover.js +11 -2
- package/dist/components/popover.js.map +1 -1
- package/dist/components/series-chart-ticks.d.ts +13 -0
- package/dist/components/series-chart-ticks.js +29 -0
- package/dist/components/series-chart-ticks.js.map +1 -0
- package/dist/components/series-chart.js +33 -27
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/settings-fields.d.ts +2 -0
- package/dist/components/swatch-picker.d.ts +10 -1
- package/dist/components/swatch-picker.js +2 -0
- package/dist/components/swatch-picker.js.map +1 -1
- package/dist/components/swipeable-row.d.ts +11 -2
- package/dist/components/swipeable-row.js +5 -3
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/tile-radio.js +5 -7
- package/dist/components/tile-radio.js.map +1 -1
- package/dist/components/time-input.d.ts +2 -0
- package/dist/components/toggle-group.d.ts +13 -3
- package/dist/components/toggle-group.js +21 -1
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/toggle-legend.js +1 -2
- package/dist/components/toggle-legend.js.map +1 -1
- package/dist/components/tooltip.d.ts +10 -4
- package/dist/components/tooltip.js +23 -6
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/treemap.d.ts +6 -1
- package/dist/components/treemap.js +16 -6
- package/dist/components/treemap.js.map +1 -1
- package/dist/components/ui.d.ts +36 -8
- package/dist/components/ui.js +27 -19
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-anchor-dir.d.ts +15 -0
- package/dist/components/use-anchor-dir.js +14 -0
- package/dist/components/use-anchor-dir.js.map +1 -0
- package/dist/components/use-mobile-reveal.d.ts +8 -1
- package/dist/components/use-mobile-reveal.js +3 -2
- package/dist/components/use-mobile-reveal.js.map +1 -1
- package/dist/components/use-table-state.d.ts +4 -4
- package/dist/components/use-table-state.js +12 -5
- package/dist/components/use-table-state.js.map +1 -1
- package/dist/{data-table-filters-DM1auUfH.d.ts → data-table-filters-noy0Abvi.d.ts} +40 -7
- package/dist/data-table.d.ts +1 -1
- package/dist/data-table.js.map +1 -1
- package/dist/feedback/feedback-attachment.d.ts +22 -3
- package/dist/feedback/feedback-attachment.js +15 -7
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-dialog.d.ts +3 -2
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback.d.ts +1 -1
- package/dist/hooks/use-windowed-rows.d.ts +29 -0
- package/dist/hooks/use-windowed-rows.js +31 -0
- package/dist/hooks/use-windowed-rows.js.map +1 -0
- package/dist/i18n/defaults.d.ts +5 -0
- package/dist/i18n/defaults.js +5 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +7 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH.d.ts +45 -0
- package/dist/i18n/locales/de-CH.js +21 -0
- package/dist/i18n/locales/de-CH.js.map +1 -0
- package/dist/i18n/locales/de.d.ts +45 -0
- package/dist/i18n/locales/de.js +344 -0
- package/dist/i18n/locales/de.js.map +1 -0
- package/dist/i18n/locales/es.d.ts +45 -0
- package/dist/i18n/locales/es.js +339 -0
- package/dist/i18n/locales/es.js.map +1 -0
- package/dist/i18n/locales/fr.d.ts +45 -0
- package/dist/i18n/locales/fr.js +344 -0
- package/dist/i18n/locales/fr.js.map +1 -0
- package/dist/i18n/locales/hu.d.ts +45 -0
- package/dist/i18n/locales/hu.js +343 -0
- package/dist/i18n/locales/hu.js.map +1 -0
- package/dist/i18n/locales/it.d.ts +45 -0
- package/dist/i18n/locales/it.js +342 -0
- package/dist/i18n/locales/it.js.map +1 -0
- package/dist/i18n/locales/zh.d.ts +45 -0
- package/dist/i18n/locales/zh.js +338 -0
- package/dist/i18n/locales/zh.js.map +1 -0
- package/dist/index.d.ts +7 -4
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/direction.d.ts +24 -0
- package/dist/lib/direction.js +19 -0
- package/dist/lib/direction.js.map +1 -0
- package/dist/rhf/form.d.ts +2 -0
- package/dist/rhf/form.js.map +1 -1
- package/dist/rhf.d.ts +2 -0
- package/dist/search/command-palette.d.ts +5 -0
- package/dist/search/command-palette.js +80 -50
- package/dist/search/command-palette.js.map +1 -1
- package/dist/shell/app-shell.d.ts +13 -1
- package/dist/shell/app-shell.js +134 -85
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/topbar-action-menu.js +1 -1
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell/topbar-controls.d.ts +7 -3
- package/dist/shell/topbar-controls.js +38 -31
- package/dist/shell/topbar-controls.js.map +1 -1
- package/dist/theme/chart-palette.d.ts +7 -1
- package/dist/theme/chart-palette.js +2 -1
- package/dist/theme/chart-palette.js.map +1 -1
- package/dist/tour/tour.d.ts +11 -5
- package/dist/tour/tour.js +124 -58
- package/dist/tour/tour.js.map +1 -1
- package/dist/wizard/use-wizard.js +30 -13
- package/dist/wizard/use-wizard.js.map +1 -1
- package/dist/wizard/wizard-summary.js +1 -1
- package/dist/wizard/wizard-summary.js.map +1 -1
- package/package.json +12 -2
- package/src/components/account-settings.tsx +6 -2
- package/src/components/amount-input.tsx +10 -8
- package/src/components/autocomplete.tsx +110 -24
- package/src/components/calculator.tsx +5 -1
- package/src/components/chart-zoom.tsx +8 -1
- package/src/components/chart.tsx +68 -7
- package/src/components/chip.tsx +131 -49
- package/src/components/choice-card.tsx +77 -36
- package/src/components/combobox-core.tsx +147 -20
- package/src/components/combobox.tsx +100 -44
- package/src/components/currency-select.tsx +6 -6
- package/src/components/danger-confirm.tsx +90 -36
- package/src/components/data-table-pagination.tsx +32 -19
- package/src/components/data-table.tsx +198 -36
- package/src/components/date-picker.tsx +24 -7
- package/src/components/dialog-frame.tsx +97 -22
- package/src/components/disclosure.tsx +75 -13
- package/src/components/dropdown.tsx +17 -5
- package/src/components/entity-combobox.tsx +26 -13
- package/src/components/file-button.tsx +74 -5
- package/src/components/file-dropzone.tsx +44 -35
- package/src/components/grouped-picker.tsx +2 -2
- package/src/components/hover-menu.tsx +139 -10
- package/src/components/icon-picker.tsx +14 -1
- package/src/components/measured-grid.tsx +852 -0
- package/src/components/mini-calendar.tsx +11 -8
- package/src/components/month-picker.tsx +18 -11
- package/src/components/multi-entity-combobox.tsx +2 -2
- package/src/components/multi-select.tsx +34 -7
- package/src/components/number-input.tsx +4 -4
- package/src/components/numpad-sheet.tsx +5 -3
- package/src/components/picker-sheet.tsx +1 -1
- package/src/components/popover.tsx +18 -3
- package/src/components/series-chart-ticks.ts +52 -0
- package/src/components/series-chart.tsx +39 -31
- package/src/components/swatch-picker.tsx +11 -0
- package/src/components/swipeable-row.tsx +21 -6
- package/src/components/tile-radio.tsx +5 -7
- package/src/components/toggle-group.tsx +39 -3
- package/src/components/toggle-legend.tsx +5 -2
- package/src/components/tooltip.tsx +40 -12
- package/src/components/treemap.tsx +28 -5
- package/src/components/ui.tsx +79 -33
- package/src/components/use-anchor-dir.ts +20 -0
- package/src/components/use-mobile-reveal.ts +10 -2
- package/src/components/use-table-state.ts +44 -7
- package/src/data-table.ts +1 -0
- package/src/feedback/feedback-attachment.tsx +36 -9
- package/src/feedback/feedback-dialog.tsx +3 -2
- package/src/hooks/use-windowed-rows.ts +69 -0
- package/src/i18n/defaults.ts +4 -0
- package/src/i18n/kit-labels.tsx +4 -0
- package/src/i18n/locales/de-CH.ts +30 -0
- package/src/i18n/locales/de.ts +365 -0
- package/src/i18n/locales/es.ts +358 -0
- package/src/i18n/locales/fr.ts +371 -0
- package/src/i18n/locales/hu.ts +359 -0
- package/src/i18n/locales/it.ts +360 -0
- package/src/i18n/locales/zh.ts +348 -0
- package/src/index.ts +10 -1
- package/src/lib/direction.ts +37 -0
- package/src/rhf/form.tsx +21 -0
- package/src/search/command-palette.tsx +77 -19
- package/src/shell/app-shell.tsx +201 -130
- package/src/shell/topbar-action-menu.tsx +1 -1
- package/src/shell/topbar-controls.tsx +19 -8
- package/src/theme/chart-palette.ts +9 -2
- package/src/tour/tour.tsx +119 -18
- package/src/wizard/use-wizard.ts +55 -17
- package/src/wizard/wizard-summary.tsx +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { ComponentPropsWithoutRef } from "react";
|
|
1
|
+
import type { ComponentPropsWithoutRef, KeyboardEvent, ReactElement } from "react";
|
|
2
2
|
import { cn } from "../lib/cn";
|
|
3
|
+
import { horizontalStep } from "../lib/direction";
|
|
3
4
|
|
|
4
5
|
export interface ToggleOption<T extends string> {
|
|
5
6
|
value: T;
|
|
@@ -74,7 +75,19 @@ export type ToggleGroupProps<T extends string> =
|
|
|
74
75
|
| ToggleGroupRequiredProps<T>
|
|
75
76
|
| ToggleGroupClearableProps<T>;
|
|
76
77
|
|
|
77
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Overloaded rather than typed by the union alone (keksdose, "Gaps found adopting
|
|
80
|
+
* 0.6.0" #7). Inferring `T` through a union of prop shapes let TypeScript settle on
|
|
81
|
+
* `string`, so `<ToggleGroup allowEmpty value={filter} onChange={setFilter} />` over a
|
|
82
|
+
* `useState<Status | null>` did not compile unless the caller spelled
|
|
83
|
+
* `<ToggleGroup<Status>>`. One signature per mode lets each infer `T` from its own
|
|
84
|
+
* `value`/`onChange`/`options`; the third keeps a caller that forwards a
|
|
85
|
+
* {@link ToggleGroupProps} union (a wrapper component) compiling.
|
|
86
|
+
*/
|
|
87
|
+
export function ToggleGroup<T extends string>(props: ToggleGroupClearableProps<T>): ReactElement;
|
|
88
|
+
export function ToggleGroup<T extends string>(props: ToggleGroupRequiredProps<T>): ReactElement;
|
|
89
|
+
export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): ReactElement;
|
|
90
|
+
export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): ReactElement {
|
|
78
91
|
const {
|
|
79
92
|
value,
|
|
80
93
|
options,
|
|
@@ -93,6 +106,27 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>) {
|
|
|
93
106
|
else props.onChange(next);
|
|
94
107
|
};
|
|
95
108
|
const clearable = props.allowEmpty === true;
|
|
109
|
+
// A radio group is ONE tab stop (the checked radio, else the first) and arrows move
|
|
110
|
+
// the choice — the pattern `role="radiogroup"` promises a screen-reader user. Before
|
|
111
|
+
// 0.7.0 each segment was its own tab stop with no arrow keys. The clearable mode is a
|
|
112
|
+
// row of toggle buttons, where separate tab stops are the pattern.
|
|
113
|
+
const tabStop = options.some((o) => o.value === value) ? value : options[0]?.value;
|
|
114
|
+
const onRadioKey = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
|
115
|
+
const last = options.length - 1;
|
|
116
|
+
let next: number | null = null;
|
|
117
|
+
const step = horizontalStep(e.key, e.currentTarget);
|
|
118
|
+
if (step !== 0) next = index + step;
|
|
119
|
+
else if (e.key === "ArrowDown") next = index + 1;
|
|
120
|
+
else if (e.key === "ArrowUp") next = index - 1;
|
|
121
|
+
else if (e.key === "Home") next = 0;
|
|
122
|
+
else if (e.key === "End") next = last;
|
|
123
|
+
if (next === null) return;
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
next = next < 0 ? last : next > last ? 0 : next;
|
|
126
|
+
const buttons = e.currentTarget.parentElement?.querySelectorAll<HTMLButtonElement>(":scope > button");
|
|
127
|
+
buttons?.[next]?.focus();
|
|
128
|
+
choose(options[next].value);
|
|
129
|
+
};
|
|
96
130
|
return (
|
|
97
131
|
<div
|
|
98
132
|
// The audit's named example of a closed prop list (§"Public API design"): the
|
|
@@ -135,7 +169,7 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>) {
|
|
|
135
169
|
className,
|
|
136
170
|
)}
|
|
137
171
|
>
|
|
138
|
-
{options.map((opt) => {
|
|
172
|
+
{options.map((opt, index) => {
|
|
139
173
|
const active = opt.value === value;
|
|
140
174
|
return (
|
|
141
175
|
<button
|
|
@@ -145,6 +179,8 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>) {
|
|
|
145
179
|
aria-checked={clearable ? undefined : active}
|
|
146
180
|
aria-pressed={clearable ? active : undefined}
|
|
147
181
|
disabled={disabled}
|
|
182
|
+
tabIndex={clearable ? undefined : opt.value === tabStop ? 0 : -1}
|
|
183
|
+
onKeyDown={clearable ? undefined : (e) => onRadioKey(e, index)}
|
|
148
184
|
onClick={() => choose(opt.value)}
|
|
149
185
|
className={cn(
|
|
150
186
|
// `truncate` (which carries whitespace-nowrap) rather than letting a
|
|
@@ -198,7 +198,11 @@ export function LegendGroup({ title, children }: { title: ReactNode; children: R
|
|
|
198
198
|
* front of it.
|
|
199
199
|
*
|
|
200
200
|
* A stroke is an SVG line with the chart's own `strokeDasharray`, not a CSS gradient
|
|
201
|
-
* approximating it: a gradient can say "solid or dashed" and nothing more.
|
|
201
|
+
* approximating it: a gradient can say "solid or dashed" and nothing more.
|
|
202
|
+
*
|
|
203
|
+
* A hidden stroke is NOT faded here: the button around it already is (`opacity-35`),
|
|
204
|
+
* and the line used to take its own 0.35 on top — 0.35 × 0.35, about 12 %, a mark
|
|
205
|
+
* gone rather than dimmed, while the square beside it read at the full 35 %. */
|
|
202
206
|
function LegendMark({ entry, off }: { entry: LegendEntry; off: boolean }) {
|
|
203
207
|
if (entry.marker === "stroke") {
|
|
204
208
|
return (
|
|
@@ -211,7 +215,6 @@ function LegendMark({ entry, off }: { entry: LegendEntry; off: boolean }) {
|
|
|
211
215
|
stroke={entry.color}
|
|
212
216
|
strokeWidth={2}
|
|
213
217
|
strokeDasharray={strokeDash(entry.dash ?? 0)}
|
|
214
|
-
opacity={off ? 0.35 : 1}
|
|
215
218
|
/>
|
|
216
219
|
</svg>
|
|
217
220
|
);
|
|
@@ -13,8 +13,22 @@ import { createPortal } from "react-dom";
|
|
|
13
13
|
import { cn } from "../lib/cn";
|
|
14
14
|
import { useEscapeKey } from "../hooks/use-dismiss";
|
|
15
15
|
import { useAnchoredRect, type AnchorRect } from "../hooks/use-anchored-rect";
|
|
16
|
+
import { dirOf, type Direction } from "../lib/direction";
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
/** Where the bubble sits. `start` / `end` follow the reading direction — `end` is the
|
|
19
|
+
* right in LTR and the left in RTL — and are what a layout that mirrors should ask
|
|
20
|
+
* for. `left` / `right` stay physical, for a bubble tied to something that does not
|
|
21
|
+
* mirror (a chart axis, a map). */
|
|
22
|
+
export type TooltipSide = "top" | "bottom" | "left" | "right" | "start" | "end";
|
|
23
|
+
|
|
24
|
+
/** The placement the maths works in: a logical side resolved against the trigger. */
|
|
25
|
+
type PhysicalSide = "top" | "bottom" | "left" | "right";
|
|
26
|
+
|
|
27
|
+
function physicalSide(side: TooltipSide, dir: Direction): PhysicalSide {
|
|
28
|
+
if (side === "start") return dir === "rtl" ? "right" : "left";
|
|
29
|
+
if (side === "end") return dir === "rtl" ? "left" : "right";
|
|
30
|
+
return side;
|
|
31
|
+
}
|
|
18
32
|
|
|
19
33
|
/** The floating bubble itself. Uses the shared surface/border/text tokens so it
|
|
20
34
|
* reads as part of the app's chrome (like the top bar and cards) rather than the
|
|
@@ -37,6 +51,10 @@ const sidePositionClass: Record<TooltipSide, string> = {
|
|
|
37
51
|
bottom: "top-full left-1/2 -translate-x-1/2 mt-1",
|
|
38
52
|
left: "right-full top-1/2 -translate-y-1/2 mr-1",
|
|
39
53
|
right: "left-full top-1/2 -translate-y-1/2 ml-1",
|
|
54
|
+
// Logical insets, so CSS resolves the side from the inherited direction with no
|
|
55
|
+
// JavaScript: `end-full` pins the bubble's END edge to the trigger's start.
|
|
56
|
+
start: "end-full top-1/2 -translate-y-1/2 me-1",
|
|
57
|
+
end: "start-full top-1/2 -translate-y-1/2 ms-1",
|
|
40
58
|
};
|
|
41
59
|
|
|
42
60
|
/**
|
|
@@ -248,7 +266,7 @@ const TOOLTIP_GAP = 4;
|
|
|
248
266
|
* against the glass reads as clipped even when every character is on screen. */
|
|
249
267
|
const TOOLTIP_MARGIN = 4;
|
|
250
268
|
|
|
251
|
-
const portalTransformBySide: Record<
|
|
269
|
+
const portalTransformBySide: Record<PhysicalSide, string> = {
|
|
252
270
|
right: "translate(0, -50%)",
|
|
253
271
|
left: "translate(-100%, -50%)",
|
|
254
272
|
top: "translate(-50%, -100%)",
|
|
@@ -259,7 +277,7 @@ const portalTransformBySide: Record<TooltipSide, string> = {
|
|
|
259
277
|
* with {@link portalTransformBySide}, which shifts the box onto that point. */
|
|
260
278
|
function tooltipAnchor(
|
|
261
279
|
r: AnchorRect,
|
|
262
|
-
side:
|
|
280
|
+
side: PhysicalSide,
|
|
263
281
|
): { left: number; top: number } {
|
|
264
282
|
switch (side) {
|
|
265
283
|
case "right":
|
|
@@ -287,10 +305,10 @@ export interface TooltipPlacement {
|
|
|
287
305
|
left: number;
|
|
288
306
|
top: number;
|
|
289
307
|
/** Which side it ended up on, which need not be the one that was asked for. */
|
|
290
|
-
side:
|
|
308
|
+
side: PhysicalSide;
|
|
291
309
|
}
|
|
292
310
|
|
|
293
|
-
const opposite: Record<
|
|
311
|
+
const opposite: Record<PhysicalSide, PhysicalSide> = {
|
|
294
312
|
left: "right",
|
|
295
313
|
right: "left",
|
|
296
314
|
top: "bottom",
|
|
@@ -300,7 +318,7 @@ const opposite: Record<TooltipSide, TooltipSide> = {
|
|
|
300
318
|
/** Whether the bubble clears the viewport edge on `side` of the trigger. */
|
|
301
319
|
function roomOn(
|
|
302
320
|
r: AnchorRect,
|
|
303
|
-
side:
|
|
321
|
+
side: PhysicalSide,
|
|
304
322
|
size: TooltipSize,
|
|
305
323
|
viewport: TooltipViewport,
|
|
306
324
|
): boolean {
|
|
@@ -359,7 +377,7 @@ function clamp(value: number, low: number, high: number): number {
|
|
|
359
377
|
*/
|
|
360
378
|
export function placeTooltip(
|
|
361
379
|
r: AnchorRect,
|
|
362
|
-
side:
|
|
380
|
+
side: PhysicalSide,
|
|
363
381
|
size: TooltipSize,
|
|
364
382
|
viewport: TooltipViewport,
|
|
365
383
|
): TooltipPlacement {
|
|
@@ -394,6 +412,14 @@ function PortalTooltip({
|
|
|
394
412
|
const triggerRef = useRef<HTMLSpanElement | null>(null);
|
|
395
413
|
const bubbleRef = useRef<HTMLSpanElement | null>(null);
|
|
396
414
|
const [visible, setVisible] = useState(false);
|
|
415
|
+
// The trigger's reading direction, read when the bubble is asked for (an event, not a
|
|
416
|
+
// render): it resolves `start` / `end`, and the portalled bubble — which has left the
|
|
417
|
+
// subtree it would have inherited `dir` from — carries it too.
|
|
418
|
+
const [dir, setDir] = useState<Direction>("ltr");
|
|
419
|
+
const show = (el: Element) => {
|
|
420
|
+
setDir(dirOf(el));
|
|
421
|
+
setVisible(true);
|
|
422
|
+
};
|
|
397
423
|
const id = useId();
|
|
398
424
|
// Escape closes it outright, since this variant's bubble only exists while it is
|
|
399
425
|
// shown. The next mouseenter/focus brings it back, which is the behaviour WCAG
|
|
@@ -424,11 +450,12 @@ function PortalTooltip({
|
|
|
424
450
|
});
|
|
425
451
|
}, [visible, rect, label]);
|
|
426
452
|
|
|
427
|
-
const
|
|
453
|
+
const physical = physicalSide(side, dir);
|
|
454
|
+
const point = rect ? tooltipAnchor(rect, physical) : null;
|
|
428
455
|
// Unmeasured on the very first pass, where the anchor point plus the side's
|
|
429
456
|
// own transform is exactly what this always did. One layout effect later the
|
|
430
457
|
// size is known and the placement is decided properly.
|
|
431
|
-
const placed = rect && room ? placeTooltip(rect,
|
|
458
|
+
const placed = rect && room ? placeTooltip(rect, physical, room.size, room.viewport) : null;
|
|
432
459
|
|
|
433
460
|
return (
|
|
434
461
|
<>
|
|
@@ -440,9 +467,9 @@ function PortalTooltip({
|
|
|
440
467
|
{...rest}
|
|
441
468
|
ref={triggerRef}
|
|
442
469
|
className={cn("relative inline-flex", className)}
|
|
443
|
-
onMouseEnter={() =>
|
|
470
|
+
onMouseEnter={(e) => show(e.currentTarget)}
|
|
444
471
|
onMouseLeave={() => setVisible(false)}
|
|
445
|
-
onFocus={() =>
|
|
472
|
+
onFocus={(e) => show(e.currentTarget)}
|
|
446
473
|
onBlur={() => setVisible(false)}
|
|
447
474
|
>
|
|
448
475
|
{describedBy(children, visible ? id : undefined)}
|
|
@@ -455,6 +482,7 @@ function PortalTooltip({
|
|
|
455
482
|
ref={bubbleRef}
|
|
456
483
|
id={id}
|
|
457
484
|
role="tooltip"
|
|
485
|
+
dir={dir}
|
|
458
486
|
data-private={redact ? "" : undefined}
|
|
459
487
|
style={
|
|
460
488
|
placed
|
|
@@ -463,7 +491,7 @@ function PortalTooltip({
|
|
|
463
491
|
position: "fixed",
|
|
464
492
|
left: point.left,
|
|
465
493
|
top: point.top,
|
|
466
|
-
transform: portalTransformBySide[
|
|
494
|
+
transform: portalTransformBySide[physical],
|
|
467
495
|
}
|
|
468
496
|
}
|
|
469
497
|
className={cn(TOOLTIP_SURFACE, "pointer-events-none z-50")}
|
|
@@ -8,12 +8,13 @@
|
|
|
8
8
|
// chart. The one thing it cannot take from a CSS var is the tile colour: the label ink
|
|
9
9
|
// is MEASURED against the fill, and a `var(--chart-3)` string measures as nothing — see
|
|
10
10
|
// `useChartTokenHex` below for how the concrete hex is found instead.
|
|
11
|
-
import { useSyncExternalStore } from "react";
|
|
11
|
+
import { useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
12
12
|
import type { ComponentProps, ComponentPropsWithoutRef, KeyboardEvent, ReactNode } from "react";
|
|
13
13
|
import { Treemap as RechartsTreemap } from "recharts";
|
|
14
14
|
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "./chart";
|
|
15
15
|
import { PALETTE_HEX, textOn } from "../theme/chart-palette";
|
|
16
16
|
import { parseHex } from "../theme/color";
|
|
17
|
+
import { dirOf, type Direction } from "../lib/direction";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* One tile.
|
|
@@ -174,6 +175,10 @@ export interface TreemapCellProps {
|
|
|
174
175
|
onNodeClick?: (id: string, name: string) => void;
|
|
175
176
|
redactNames?: boolean;
|
|
176
177
|
nodeNote?: (id: string) => string | undefined;
|
|
178
|
+
/** The page's reading direction, which `Treemap` reads off its own root. In `rtl`
|
|
179
|
+
* the label stands in the tile's top RIGHT corner and is shaped right-to-left, so a
|
|
180
|
+
* clipped name ends in its ellipsis on the reading end. Default `ltr`. */
|
|
181
|
+
dir?: Direction;
|
|
177
182
|
}
|
|
178
183
|
|
|
179
184
|
/**
|
|
@@ -199,6 +204,7 @@ export function TreemapCell({
|
|
|
199
204
|
onNodeClick,
|
|
200
205
|
redactNames,
|
|
201
206
|
nodeNote,
|
|
207
|
+
dir = "ltr",
|
|
202
208
|
}: TreemapCellProps) {
|
|
203
209
|
// Depth 0 is recharts' synthetic root, which spans the whole chart.
|
|
204
210
|
if (!depth) return null;
|
|
@@ -220,6 +226,11 @@ export function TreemapCell({
|
|
|
220
226
|
const rawNote = id != null && nodeNote ? nodeNote(id) : undefined;
|
|
221
227
|
const note =
|
|
222
228
|
label != null && rawNote != null && ph >= 36 ? fitLabel(rawNote, pw, NOTE_FONT_SIZE) : null;
|
|
229
|
+
// The label's START edge, 6px in from the tile's. The tiles themselves are laid out
|
|
230
|
+
// physically (the plot is not mirrored, see `ChartContainer`) and the chart's SVG is
|
|
231
|
+
// pinned `ltr`, so the direction is set on each <text> explicitly: `start` is then
|
|
232
|
+
// the right end in RTL, and the label grows leftwards from the tile's right edge.
|
|
233
|
+
const textX = dir === "rtl" ? px + pw - 6 : px + 6;
|
|
223
234
|
const activate = id != null && onNodeClick ? () => onNodeClick(id, name ?? "") : undefined;
|
|
224
235
|
const onKeyDown = activate
|
|
225
236
|
? (e: KeyboardEvent<SVGGElement>) => {
|
|
@@ -261,8 +272,9 @@ export function TreemapCell({
|
|
|
261
272
|
// A plain attribute, so the host's `[data-private]` rule reaches an SVG
|
|
262
273
|
// <text> exactly as it reaches a table cell.
|
|
263
274
|
data-private={redactNames ? "" : undefined}
|
|
264
|
-
x={
|
|
275
|
+
x={textX}
|
|
265
276
|
y={py + 15}
|
|
277
|
+
direction={dir}
|
|
266
278
|
fill={ink}
|
|
267
279
|
stroke={tileFill}
|
|
268
280
|
strokeWidth={2.5}
|
|
@@ -277,8 +289,9 @@ export function TreemapCell({
|
|
|
277
289
|
)}
|
|
278
290
|
{note != null && (
|
|
279
291
|
<text
|
|
280
|
-
x={
|
|
292
|
+
x={textX}
|
|
281
293
|
y={py + 29}
|
|
294
|
+
direction={dir}
|
|
282
295
|
fill={ink}
|
|
283
296
|
fillOpacity={0.85}
|
|
284
297
|
stroke={tileFill}
|
|
@@ -350,6 +363,14 @@ export function Treemap({
|
|
|
350
363
|
...rest
|
|
351
364
|
}: TreemapProps) {
|
|
352
365
|
const tokenHex = useChartTokenHex();
|
|
366
|
+
// Read off the DOM after mount: the `dir` a treemap sits under is an ancestor's
|
|
367
|
+
// attribute, and SVG text anchoring cannot follow it by CSS (see TreemapCell). Again
|
|
368
|
+
// on new data, because a map that had nothing to draw rendered no root to read.
|
|
369
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
370
|
+
const [dir, setDir] = useState<Direction>("ltr");
|
|
371
|
+
useLayoutEffect(() => {
|
|
372
|
+
setDir(dirOf(rootRef.current));
|
|
373
|
+
}, [data]);
|
|
353
374
|
const ramp = colors && colors.length > 0 ? colors : tokenHex;
|
|
354
375
|
// Coloured by index into `data` AS GIVEN, before anything is dropped: a caller
|
|
355
376
|
// painting a legend or a second map from the same list must land on the same hue
|
|
@@ -373,7 +394,7 @@ export function Treemap({
|
|
|
373
394
|
};
|
|
374
395
|
const Content = redactNames ? RedactedTooltipContent : ChartTooltipContent;
|
|
375
396
|
return (
|
|
376
|
-
<ChartContainer {...rest} config={TREEMAP_CONFIG} style={{ ...style, height }}>
|
|
397
|
+
<ChartContainer {...rest} ref={rootRef} config={TREEMAP_CONFIG} style={{ ...style, height }}>
|
|
377
398
|
{/* No chart-level `stroke`: recharts spreads it onto the <svg> root, from where
|
|
378
399
|
it inherits into every label (see TreemapCell). Each cell strokes its own rect. */}
|
|
379
400
|
<RechartsTreemap
|
|
@@ -381,7 +402,9 @@ export function Treemap({
|
|
|
381
402
|
dataKey="size"
|
|
382
403
|
fill="var(--chart-1)"
|
|
383
404
|
isAnimationActive={false}
|
|
384
|
-
content={
|
|
405
|
+
content={
|
|
406
|
+
<TreemapCell onNodeClick={onNodeClick} redactNames={redactNames} nodeNote={nodeNote} dir={dir} />
|
|
407
|
+
}
|
|
385
408
|
>
|
|
386
409
|
<ChartTooltip content={<Content hideLabel valueFormatter={format} />} />
|
|
387
410
|
</RechartsTreemap>
|
package/src/components/ui.tsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
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 { ButtonHTMLAttributes, ComponentPropsWithoutRef, InputHTMLAttributes, KeyboardEvent, MouseEvent, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
|
|
3
|
+
import type { ButtonHTMLAttributes, ComponentPropsWithoutRef, InputHTMLAttributes, KeyboardEvent, MouseEvent, ReactNode, Ref, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
|
|
4
4
|
import { cn } from "../lib/cn";
|
|
5
|
+
import { horizontalStep } from "../lib/direction";
|
|
5
6
|
import { useMediaQuery } from "../hooks/use-media-query";
|
|
6
|
-
import { Tooltip } from "./tooltip";
|
|
7
|
+
import { Tooltip, type TooltipSide } from "./tooltip";
|
|
7
8
|
import { DEFAULT_COMMON_LABELS, useKitLabels } from "../i18n/kit-labels";
|
|
8
9
|
|
|
9
10
|
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "brand";
|
|
@@ -49,6 +50,10 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
|
49
50
|
/** In a flex row next to a taller labelled field, fill the field's height so the
|
|
50
51
|
* two line up. No effect outside a flex row. */
|
|
51
52
|
stretch?: boolean;
|
|
53
|
+
/** The `<button>` element. React 19 passes `ref` to a function component as an
|
|
54
|
+
* ordinary prop, so it rides `...rest` onto the element with no `forwardRef` —
|
|
55
|
+
* declared here only because `ButtonHTMLAttributes` does not carry it. */
|
|
56
|
+
ref?: Ref<HTMLButtonElement>;
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
export function Button({ variant = "primary", stretch, className, ...rest }: ButtonProps) {
|
|
@@ -275,7 +280,7 @@ export const FIELD_DISPLAY =
|
|
|
275
280
|
// button the way the native Select does.
|
|
276
281
|
export const FIELD_TRIGGER = cn(
|
|
277
282
|
FIELD_BASE,
|
|
278
|
-
"relative flex items-center justify-between gap-2 text-
|
|
283
|
+
"relative flex items-center justify-between gap-2 text-start hover:bg-[var(--bg-hover)]",
|
|
279
284
|
);
|
|
280
285
|
|
|
281
286
|
/** An alias rather than an interface: the chevron adds nothing of its own to an
|
|
@@ -290,7 +295,10 @@ export type FieldChevronProps = Omit<ComponentPropsWithoutRef<"svg">, "children"
|
|
|
290
295
|
* it centres on the *content* box instead, and `FIELD_FLOATING_PAD` (pt-4 pb-1)
|
|
291
296
|
* pushes that box's midline down — so a labelled currency/multi-select chevron
|
|
292
297
|
* sat visibly lower than the native select's right beside it (feedback #400).
|
|
293
|
-
* Pair it with `
|
|
298
|
+
* Pair it with `pe-9` on the trigger so the value can't run underneath. At the
|
|
299
|
+
* inline END, not the right: a right-to-left form reads the value from the right,
|
|
300
|
+
* and a chevron parked on top of its first word hid exactly the part that says
|
|
301
|
+
* what was picked. */
|
|
294
302
|
export function FieldChevron({ className, ...rest }: FieldChevronProps) {
|
|
295
303
|
return (
|
|
296
304
|
<ChevronDown
|
|
@@ -300,7 +308,7 @@ export function FieldChevron({ className, ...rest }: FieldChevronProps) {
|
|
|
300
308
|
// announcement of the same field.
|
|
301
309
|
aria-hidden
|
|
302
310
|
className={cn(
|
|
303
|
-
"pointer-events-none absolute
|
|
311
|
+
"pointer-events-none absolute end-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]",
|
|
304
312
|
className,
|
|
305
313
|
)}
|
|
306
314
|
/>
|
|
@@ -311,7 +319,7 @@ export function FieldChevron({ className, ...rest }: FieldChevronProps) {
|
|
|
311
319
|
// floats up INSIDE the top strip on focus or once the field has a value. Sits on
|
|
312
320
|
// the field's own surface, so no background chip and nothing to mismatch the card.
|
|
313
321
|
export const FLOATING_LABEL_CLASS = cn(
|
|
314
|
-
"pointer-events-none absolute
|
|
322
|
+
"pointer-events-none absolute start-3 top-2.5 text-sm text-[var(--text-placeholder)] transition-all",
|
|
315
323
|
"max-w-[calc(100%-1.5rem)] truncate",
|
|
316
324
|
"peer-focus:top-1 peer-focus:text-[11px] peer-focus:leading-tight peer-focus:text-[var(--text-secondary)]",
|
|
317
325
|
"peer-[:not(:placeholder-shown)]:top-1 peer-[:not(:placeholder-shown)]:text-[11px] peer-[:not(:placeholder-shown)]:leading-tight peer-[:not(:placeholder-shown)]:text-[var(--text-secondary)]",
|
|
@@ -324,13 +332,13 @@ export const FLOATING_LABEL_CLASS = cn(
|
|
|
324
332
|
// selector, so the classes have to sit on the element that is actually a sibling
|
|
325
333
|
// of the input, and a label nested inside a wrapper is not one.
|
|
326
334
|
//
|
|
327
|
-
// Wider
|
|
335
|
+
// Wider end clearance than the label alone takes (`3rem` rather than `1.5rem`),
|
|
328
336
|
// because the controls that carry an animated label are the ones with something
|
|
329
|
-
// at the
|
|
337
|
+
// at the end edge of the field — NumberInput's calculator is the case this was
|
|
330
338
|
// written for. The label truncates a little sooner; the alternative was the "?"
|
|
331
339
|
// sitting on top of a button (steering-design feedback #48).
|
|
332
340
|
const FLOATING_ROW_CLASS = cn(
|
|
333
|
-
"pointer-events-none absolute
|
|
341
|
+
"pointer-events-none absolute start-3 top-2.5 flex items-center gap-1 transition-all",
|
|
334
342
|
"max-w-[calc(100%-3rem)] text-sm text-[var(--text-placeholder)]",
|
|
335
343
|
"peer-focus:top-1 peer-focus:text-[11px] peer-focus:leading-tight peer-focus:text-[var(--text-secondary)]",
|
|
336
344
|
"peer-[:not(:placeholder-shown)]:top-1 peer-[:not(:placeholder-shown)]:text-[11px] peer-[:not(:placeholder-shown)]:leading-tight peer-[:not(:placeholder-shown)]:text-[var(--text-secondary)]",
|
|
@@ -348,7 +356,7 @@ const STATIC_LABEL_TYPE =
|
|
|
348
356
|
// A field that always has a value (select / dropdown trigger) keeps the label
|
|
349
357
|
// permanently in the floated position — small, in the top strip, value below.
|
|
350
358
|
export const FLOATING_LABEL_STATIC = cn(
|
|
351
|
-
"pointer-events-none absolute
|
|
359
|
+
"pointer-events-none absolute start-3 top-1",
|
|
352
360
|
STATIC_LABEL_TYPE,
|
|
353
361
|
"max-w-[calc(100%-1.5rem)] truncate",
|
|
354
362
|
);
|
|
@@ -377,7 +385,7 @@ export interface FloatingFieldProps extends ComponentPropsWithoutRef<"div"> {
|
|
|
377
385
|
* It rides an ANIMATED label too, and the two cases place it differently on
|
|
378
386
|
* purpose. A static label has a field-wide strip to itself, so the hint sits
|
|
379
387
|
* at the far end of it and a long label truncates into the gap. An animated
|
|
380
|
-
* one belongs to a control with something at the
|
|
388
|
+
* one belongs to a control with something at the end edge of the field — a
|
|
381
389
|
* calculator, a stepper — so the hint follows the label instead, and it is
|
|
382
390
|
* the label that gives way (steering-design feedback #48). */
|
|
383
391
|
hint?: ReactNode;
|
|
@@ -422,8 +430,8 @@ export function FloatingField({
|
|
|
422
430
|
<div {...rest} className={cn("relative", className)}>
|
|
423
431
|
{children}
|
|
424
432
|
{withHint ? (
|
|
425
|
-
// Static: `inset-x-3` rather than `
|
|
426
|
-
// the field's own
|
|
433
|
+
// Static: `inset-x-3` rather than `start-3`, so a long label truncates at
|
|
434
|
+
// the field's own end padding instead of running under the chevron.
|
|
427
435
|
// Animated: the row floats with the label and is only as wide as it needs
|
|
428
436
|
// to be. Either way the hint keeps its width (`shrink-0`) and the label
|
|
429
437
|
// is the one that gives way.
|
|
@@ -464,12 +472,14 @@ export interface FieldHintProps extends Omit<ComponentPropsWithoutRef<"button">,
|
|
|
464
472
|
/** The explanation. It is both the tooltip's text and, by default, the button's
|
|
465
473
|
* accessible name, so a screen reader gets it without the bubble ever opening. */
|
|
466
474
|
label: string;
|
|
467
|
-
|
|
475
|
+
/** Default `"start"`: before the hint in the reading direction (the left in LTR, the
|
|
476
|
+
* right in RTL). `left` / `right` stay physical. */
|
|
477
|
+
side?: TooltipSide;
|
|
468
478
|
}
|
|
469
479
|
|
|
470
480
|
export function FieldHint({
|
|
471
481
|
label,
|
|
472
|
-
side = "
|
|
482
|
+
side = "start",
|
|
473
483
|
className,
|
|
474
484
|
"aria-label": ariaLabel,
|
|
475
485
|
...rest
|
|
@@ -765,7 +775,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
|
|
765
775
|
disabled={rest.disabled}
|
|
766
776
|
aria-label={revealed ? passwordText.hide : passwordText.show}
|
|
767
777
|
aria-pressed={revealed}
|
|
768
|
-
className="absolute inset-y-0
|
|
778
|
+
className="absolute inset-y-0 end-0 flex items-center rounded-e-md px-2.5 text-[var(--text-placeholder)] transition-colors hover:text-[var(--text-secondary)] focus:outline-none focus:ring-2 focus:ring-[var(--brand)] disabled:cursor-default disabled:opacity-50"
|
|
769
779
|
>
|
|
770
780
|
{revealed ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
|
771
781
|
</button>
|
|
@@ -804,7 +814,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
|
|
804
814
|
{...rest}
|
|
805
815
|
aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
|
|
806
816
|
aria-describedby={describedBy}
|
|
807
|
-
className={cn(FIELD_BASE, "
|
|
817
|
+
className={cn(FIELD_BASE, "pe-9", inputClassName, isInvalid && FIELD_INVALID)}
|
|
808
818
|
/>
|
|
809
819
|
{revealToggle}
|
|
810
820
|
</div>
|
|
@@ -831,7 +841,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
|
|
831
841
|
asDisplay
|
|
832
842
|
? cn(FIELD_DISPLAY, "text-xl font-semibold leading-snug")
|
|
833
843
|
: FLOATING_INPUT_CLASS,
|
|
834
|
-
isPassword && "
|
|
844
|
+
isPassword && "pe-9",
|
|
835
845
|
inputClassName,
|
|
836
846
|
isInvalid && FIELD_INVALID,
|
|
837
847
|
)}
|
|
@@ -858,7 +868,9 @@ export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement
|
|
|
858
868
|
* the tall box to float in, so a labelled Select ignores `"sm"`.
|
|
859
869
|
*
|
|
860
870
|
* A NUMBER is still the native attribute (the rows of a list box) and is passed
|
|
861
|
-
* straight through, so the one HTML meaning of `size` keeps working.
|
|
871
|
+
* straight through, so the one HTML meaning of `size` keeps working. Above 1 (as
|
|
872
|
+
* with `multiple`) the browser draws a list box, and the Select drops its chevron
|
|
873
|
+
* and the end padding reserved for it.
|
|
862
874
|
*/
|
|
863
875
|
size?: "sm" | "md" | number;
|
|
864
876
|
/** Classes for the `<select>` itself. `className` styles the WRAPPER — the box
|
|
@@ -870,7 +882,7 @@ export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement
|
|
|
870
882
|
// The compact select: the field's colours, a toolbar button's height. `py-0` and a
|
|
871
883
|
// fixed height rather than a smaller padding, so the box is 28px whatever line
|
|
872
884
|
// height the caller's type brings with it.
|
|
873
|
-
const SELECT_SM = "h-7 py-0
|
|
885
|
+
const SELECT_SM = "h-7 py-0 ps-2 pe-7 text-xs";
|
|
874
886
|
|
|
875
887
|
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
|
|
876
888
|
{ className, label, id, children, invalid, error, hint, size, selectClassName, ...rest },
|
|
@@ -879,6 +891,12 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
|
|
|
879
891
|
const generated = useId();
|
|
880
892
|
// Only a number reaches the DOM; the two words are this component's own.
|
|
881
893
|
const nativeSize = typeof size === "number" ? size : undefined;
|
|
894
|
+
// A numeric size above 1 (or `multiple`) makes the browser draw a LIST BOX — all
|
|
895
|
+
// the rows in the field, no menu to drop. The dropdown dress did not fit it: the
|
|
896
|
+
// chevron was centred on a box several rows tall, sat on top of the rows' text
|
|
897
|
+
// at the end edge, and `pe-9` clipped every option nine units early. A list box
|
|
898
|
+
// gets the plain field instead — its own native scroll, no chevron, even padding.
|
|
899
|
+
const listBox = (nativeSize !== undefined && nativeSize > 1) || Boolean(rest.multiple);
|
|
882
900
|
const small = size === "sm" && label === undefined;
|
|
883
901
|
const fieldId = id ?? generated;
|
|
884
902
|
const { isInvalid, describedBy, errorEl } = useFieldError(
|
|
@@ -888,13 +906,16 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
|
|
|
888
906
|
rest["aria-invalid"],
|
|
889
907
|
);
|
|
890
908
|
// Custom chevron (native arrow hidden via appearance-none) so it sits a touch
|
|
891
|
-
// in from the
|
|
909
|
+
// in from the end border and matches both themes — feedback #223. A DISABLED
|
|
892
910
|
// select has no menu to drop, so it drops the chevron too: the arrow is the one
|
|
893
911
|
// thing on the control that promises a choice (Keksdose dev#474, where the
|
|
894
912
|
// account type became read-only and still looked exactly like a picker).
|
|
895
|
-
const chevron =
|
|
896
|
-
|
|
897
|
-
|
|
913
|
+
const chevron =
|
|
914
|
+
rest.disabled || listBox ? null : (
|
|
915
|
+
<FieldChevron className={small ? "end-1.5 size-3.5" : undefined} />
|
|
916
|
+
);
|
|
917
|
+
// `pe-9` is the room the chevron takes; a list box has none to make room for.
|
|
918
|
+
const dress = listBox ? "overflow-y-auto" : "appearance-none pe-9";
|
|
898
919
|
if (label === undefined) {
|
|
899
920
|
return (
|
|
900
921
|
<FieldGroup errorEl={errorEl}>
|
|
@@ -919,7 +940,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
|
|
|
919
940
|
aria-describedby={describedBy}
|
|
920
941
|
className={cn(
|
|
921
942
|
FIELD_BASE,
|
|
922
|
-
|
|
943
|
+
dress,
|
|
923
944
|
small && SELECT_SM,
|
|
924
945
|
selectClassName,
|
|
925
946
|
isInvalid && FIELD_INVALID,
|
|
@@ -944,8 +965,11 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
|
|
|
944
965
|
aria-describedby={describedBy}
|
|
945
966
|
className={cn(
|
|
946
967
|
FIELD_BASE,
|
|
947
|
-
|
|
948
|
-
|
|
968
|
+
// The floated label takes the top strip of a list box too, so its first
|
|
969
|
+
// row starts under the label rather than behind it.
|
|
970
|
+
listBox ? "pt-5 pb-1" : FIELD_FLOATING_PAD,
|
|
971
|
+
"peer",
|
|
972
|
+
dress,
|
|
949
973
|
selectClassName,
|
|
950
974
|
isInvalid && FIELD_INVALID,
|
|
951
975
|
)}
|
|
@@ -1320,7 +1344,8 @@ export interface TabsProps<T extends string>
|
|
|
1320
1344
|
*/
|
|
1321
1345
|
panelId?: string;
|
|
1322
1346
|
/**
|
|
1323
|
-
* Makes the tabs removable: an × on the OPEN tab, and Delete on a focused tab
|
|
1347
|
+
* Makes the tabs removable: an × on the OPEN tab, and Delete on a focused tab
|
|
1348
|
+
* (only on the open one with `removeOn="active"`).
|
|
1324
1349
|
*
|
|
1325
1350
|
* The × is on the open tab only — a row of tabs each carrying one is a row of
|
|
1326
1351
|
* things to hit by accident, and the tab you can remove should be the one you
|
|
@@ -1328,7 +1353,7 @@ export interface TabsProps<T extends string>
|
|
|
1328
1353
|
* tab never widens it and never pushes the rest of the strip along (lenkbank
|
|
1329
1354
|
* feedback #72). It is named "Remove <tab>" and is not a tab stop of its own: the
|
|
1330
1355
|
* strip stays one stop, and the keyboard path is Delete (announced through
|
|
1331
|
-
* `aria-keyshortcuts`), which removes whichever tab has focus.
|
|
1356
|
+
* `aria-keyshortcuts`), which removes whichever tab has focus (see `removeOn`).
|
|
1332
1357
|
*
|
|
1333
1358
|
* The strip only ASKS: it calls this, and the tab goes when the caller drops it
|
|
1334
1359
|
* from `tabs` — after a confirmation, a request, whatever it needs. When it does,
|
|
@@ -1336,6 +1361,20 @@ export interface TabsProps<T extends string>
|
|
|
1336
1361
|
* removed one was open, else to its neighbour, instead of falling to the page.
|
|
1337
1362
|
*/
|
|
1338
1363
|
onRemove?: (id: T) => void;
|
|
1364
|
+
/**
|
|
1365
|
+
* Which tabs the Delete key removes.
|
|
1366
|
+
*
|
|
1367
|
+
* - `"every"` (default): any focused removable tab — the ARIA pattern's reading.
|
|
1368
|
+
* - `"active"`: only the OPEN tab, the same one that wears the ×. For a strip whose
|
|
1369
|
+
* remove is immediate and unconfirmed (lenkbank's sheet tabs delete a measurement
|
|
1370
|
+
* sheet at once), so arrowing across the strip and pressing Delete cannot take
|
|
1371
|
+
* out a sheet the user is not looking at. Delete on a closed tab does nothing,
|
|
1372
|
+
* and only the open tab announces the shortcut (`aria-keyshortcuts`).
|
|
1373
|
+
*
|
|
1374
|
+
* Either way the × is drawn on the open tab only and its room stays reserved on
|
|
1375
|
+
* every removable tab, so the strip never relayouts when the selection moves.
|
|
1376
|
+
*/
|
|
1377
|
+
removeOn?: "every" | "active";
|
|
1339
1378
|
/** Renders an "add" button after the last tab, outside the tablist — it is an
|
|
1340
1379
|
* action, not a tab, and a tablist may own only tabs. */
|
|
1341
1380
|
onAdd?: () => void;
|
|
@@ -1401,6 +1440,7 @@ export function Tabs<T extends string>({
|
|
|
1401
1440
|
label,
|
|
1402
1441
|
panelId,
|
|
1403
1442
|
onRemove,
|
|
1443
|
+
removeOn = "every",
|
|
1404
1444
|
onAdd,
|
|
1405
1445
|
addLabel,
|
|
1406
1446
|
labels,
|
|
@@ -1411,6 +1451,9 @@ export function Tabs<T extends string>({
|
|
|
1411
1451
|
const text = useKitLabels("tabs", DEFAULT_TABS_LABELS, labels);
|
|
1412
1452
|
const stripRef = useRef<HTMLDivElement>(null);
|
|
1413
1453
|
const isRemovable = (tab: TabItem<T>) => onRemove !== undefined && tab.removable !== false;
|
|
1454
|
+
// Whether Delete reaches this tab: every removable tab, or only the open one.
|
|
1455
|
+
const deletesOnKey = (tab: TabItem<T>) =>
|
|
1456
|
+
isRemovable(tab) && (removeOn === "every" || tab.id === active);
|
|
1414
1457
|
const nameOf = (tab: TabItem<T>) =>
|
|
1415
1458
|
tab.name ?? (typeof tab.label === "string" ? tab.label : tab.id);
|
|
1416
1459
|
|
|
@@ -1463,13 +1506,16 @@ export function Tabs<T extends string>({
|
|
|
1463
1506
|
const onTabKeyDown = (e: KeyboardEvent<HTMLElement>, tab: TabItem<T>) => {
|
|
1464
1507
|
// Delete removes the FOCUSED tab (the ARIA pattern's optional key for deletable
|
|
1465
1508
|
// tabs) — the keyboard's way to the × that is not a tab stop. `busy` swallows it
|
|
1466
|
-
// rather than letting it fall through to the page.
|
|
1467
|
-
|
|
1509
|
+
// rather than letting it fall through to the page. With `removeOn="active"` only
|
|
1510
|
+
// the open tab answers; a closed one lets the key pass untouched.
|
|
1511
|
+
if (e.key === "Delete" && deletesOnKey(tab)) {
|
|
1468
1512
|
e.preventDefault();
|
|
1469
1513
|
if (!busy) requestRemove(tab.id);
|
|
1470
1514
|
return;
|
|
1471
1515
|
}
|
|
1472
|
-
|
|
1516
|
+
// Along the reading direction: the strip is a flex row, so in RTL the NEXT tab sits
|
|
1517
|
+
// to the left, and ArrowLeft has to reach it.
|
|
1518
|
+
const step = horizontalStep(e.key, e.currentTarget);
|
|
1473
1519
|
if (step === 0 && e.key !== "Home" && e.key !== "End") return;
|
|
1474
1520
|
const strip = e.currentTarget.closest('[role="tablist"]');
|
|
1475
1521
|
if (!strip) return;
|
|
@@ -1526,7 +1572,7 @@ export function Tabs<T extends string>({
|
|
|
1526
1572
|
// Only the open tab: see `panelId`.
|
|
1527
1573
|
"aria-controls": isActive ? panelId : undefined,
|
|
1528
1574
|
id: isActive && panelId ? `${panelId}-tab` : undefined,
|
|
1529
|
-
"aria-keyshortcuts":
|
|
1575
|
+
"aria-keyshortcuts": deletesOnKey(tab) ? "Delete" : undefined,
|
|
1530
1576
|
onKeyDown: (e: KeyboardEvent<HTMLElement>) => onTabKeyDown(e, tab),
|
|
1531
1577
|
className: cn(
|
|
1532
1578
|
wrap ? TAB_WRAP_CLASSES : TAB_CLASSES,
|