@eifi1/ui-kit 0.7.1 → 0.8.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 +32 -17
- package/dist/chart.d.ts +3 -2
- package/dist/components/alert-banner.d.ts +59 -10
- package/dist/components/alert-banner.js +114 -8
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +3 -0
- package/dist/components/button-group.d.ts +26 -0
- package/dist/components/button-group.js +49 -0
- package/dist/components/button-group.js.map +1 -0
- package/dist/components/calculator.d.ts +3 -0
- package/dist/components/chart-zoom.d.ts +63 -14
- package/dist/components/chart-zoom.js +82 -16
- package/dist/components/chart-zoom.js.map +1 -1
- package/dist/components/chip.d.ts +67 -2
- package/dist/components/chip.js +73 -8
- package/dist/components/chip.js.map +1 -1
- package/dist/components/confirm-dialog.d.ts +120 -0
- package/dist/components/confirm-dialog.js +96 -0
- package/dist/components/confirm-dialog.js.map +1 -0
- package/dist/components/copy-button.d.ts +62 -0
- package/dist/components/copy-button.js +95 -0
- package/dist/components/copy-button.js.map +1 -0
- 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 +5 -1
- package/dist/components/data-table-pagination.js +71 -58
- package/dist/components/data-table-pagination.js.map +1 -1
- package/dist/components/data-table-sort.d.ts +28 -5
- package/dist/components/data-table-sort.js +11 -7
- package/dist/components/data-table-sort.js.map +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +544 -423
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +51 -5
- package/dist/components/date-picker.js +152 -40
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/description-list.d.ts +60 -0
- package/dist/components/description-list.js +112 -0
- package/dist/components/description-list.js.map +1 -0
- package/dist/components/disclosure.d.ts +37 -2
- package/dist/components/disclosure.js +14 -5
- package/dist/components/disclosure.js.map +1 -1
- package/dist/components/facing-pair.d.ts +2 -0
- package/dist/components/file-button.d.ts +17 -1
- package/dist/components/file-button.js +8 -1
- package/dist/components/file-button.js.map +1 -1
- package/dist/components/file-dropzone.d.ts +54 -9
- package/dist/components/file-dropzone.js +100 -70
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/floating-panel.d.ts +107 -0
- package/dist/components/floating-panel.js +206 -0
- package/dist/components/floating-panel.js.map +1 -0
- package/dist/components/full-bleed-dialog.d.ts +12 -1
- package/dist/components/full-bleed-dialog.js +28 -5
- package/dist/components/full-bleed-dialog.js.map +1 -1
- package/dist/components/modal.d.ts +6 -2
- package/dist/components/modal.js +2 -1
- package/dist/components/modal.js.map +1 -1
- package/dist/components/number-field.d.ts +3 -0
- package/dist/components/number-input.d.ts +3 -0
- package/dist/components/numpad-sheet.d.ts +3 -0
- package/dist/components/progress-bar.d.ts +54 -0
- package/dist/components/progress-bar.js +94 -0
- package/dist/components/progress-bar.js.map +1 -0
- package/dist/components/scroll-area.d.ts +53 -0
- package/dist/components/scroll-area.js +86 -0
- package/dist/components/scroll-area.js.map +1 -0
- package/dist/components/separator.d.ts +23 -0
- package/dist/components/separator.js +24 -0
- package/dist/components/separator.js.map +1 -0
- package/dist/components/series-chart-ticks.d.ts +34 -1
- package/dist/components/series-chart-ticks.js +83 -1
- package/dist/components/series-chart-ticks.js.map +1 -1
- package/dist/components/series-chart.d.ts +314 -19
- package/dist/components/series-chart.js +510 -120
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/skeleton.d.ts +36 -0
- package/dist/components/skeleton.js +35 -0
- package/dist/components/skeleton.js.map +1 -0
- package/dist/components/stat-tile.d.ts +60 -6
- package/dist/components/stat-tile.js +60 -26
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/table.d.ts +83 -0
- package/dist/components/table.js +166 -0
- package/dist/components/table.js.map +1 -0
- package/dist/components/toggle-legend.d.ts +3 -2
- package/dist/components/toggle-legend.js +1 -1
- package/dist/components/toggle-legend.js.map +1 -1
- package/dist/components/tree-view.d.ts +129 -0
- package/dist/components/tree-view.js +376 -0
- package/dist/components/tree-view.js.map +1 -0
- package/dist/components/treemap.d.ts +9 -2
- package/dist/components/treemap.js +6 -2
- package/dist/components/treemap.js.map +1 -1
- package/dist/components/ui.d.ts +94 -13
- package/dist/components/ui.js +111 -29
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/{data-table-filters-noy0Abvi.d.ts → data-table-filters-CF1PXqjQ.d.ts} +73 -3
- package/dist/data-table.d.ts +2 -2
- package/dist/data-table.js.map +1 -1
- package/dist/hooks/use-copy-to-clipboard.d.ts +28 -0
- package/dist/hooks/use-copy-to-clipboard.js +81 -0
- package/dist/hooks/use-copy-to-clipboard.js.map +1 -0
- package/dist/hooks/use-debounce.d.ts +43 -0
- package/dist/hooks/use-debounce.js +56 -0
- package/dist/hooks/use-debounce.js.map +1 -0
- package/dist/hooks/use-file-drop.d.ts +72 -0
- package/dist/hooks/use-file-drop.js +57 -0
- package/dist/hooks/use-file-drop.js.map +1 -0
- package/dist/i18n/defaults.d.ts +3 -0
- package/dist/i18n/defaults.js +7 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +15 -0
- package/dist/i18n/kit-labels.js +6 -2
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +43 -0
- package/dist/i18n/locales/de-CH-informal.js +8 -0
- package/dist/i18n/locales/de-CH-informal.js.map +1 -0
- package/dist/i18n/locales/de-CH.d.ts +3 -0
- package/dist/i18n/locales/de-CH.js +1 -14
- package/dist/i18n/locales/de-CH.js.map +1 -1
- package/dist/i18n/locales/de-informal.d.ts +64 -0
- package/dist/i18n/locales/de-informal.js +37 -0
- package/dist/i18n/locales/de-informal.js.map +1 -0
- package/dist/i18n/locales/de.d.ts +3 -0
- package/dist/i18n/locales/de.js +25 -0
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +3 -0
- package/dist/i18n/locales/es.js +25 -0
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +3 -0
- package/dist/i18n/locales/fr.js +25 -0
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +3 -0
- package/dist/i18n/locales/hu.js +25 -0
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +3 -0
- package/dist/i18n/locales/it.js +25 -0
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +3 -0
- package/dist/i18n/locales/zh.js +25 -0
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/swiss.d.ts +13 -0
- package/dist/i18n/swiss.js +19 -0
- package/dist/i18n/swiss.js.map +1 -0
- package/dist/index.d.ts +25 -10
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/dates.d.ts +37 -1
- package/dist/lib/dates.js +19 -0
- package/dist/lib/dates.js.map +1 -1
- package/dist/search/command-palette.d.ts +15 -1
- package/dist/search/command-palette.js +10 -2
- package/dist/search/command-palette.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +35 -2
- package/dist/wizard/stepper-nav.js +54 -22
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard/types.d.ts +107 -5
- package/dist/wizard/types.js +2 -1
- package/dist/wizard/types.js.map +1 -1
- package/dist/wizard/use-wizard.js +141 -21
- package/dist/wizard/use-wizard.js.map +1 -1
- package/dist/wizard/wizard-summary.d.ts +10 -2
- package/dist/wizard/wizard-summary.js +3 -1
- package/dist/wizard/wizard-summary.js.map +1 -1
- package/dist/wizard.d.ts +4 -1
- package/dist/wizard.js.map +1 -1
- package/package.json +1 -1
- package/src/components/alert-banner.tsx +211 -20
- package/src/components/button-group.tsx +75 -0
- package/src/components/chart-zoom.tsx +158 -20
- package/src/components/chip.tsx +160 -9
- package/src/components/confirm-dialog.tsx +242 -0
- package/src/components/copy-button.tsx +158 -0
- package/src/components/data-table-pagination.tsx +19 -6
- package/src/components/data-table-sort.ts +49 -10
- package/src/components/data-table.tsx +293 -24
- package/src/components/date-picker.tsx +262 -57
- package/src/components/description-list.tsx +174 -0
- package/src/components/disclosure.tsx +58 -5
- package/src/components/file-button.tsx +22 -2
- package/src/components/file-dropzone.tsx +198 -100
- package/src/components/floating-panel.tsx +341 -0
- package/src/components/full-bleed-dialog.tsx +53 -5
- package/src/components/modal.tsx +9 -2
- package/src/components/progress-bar.tsx +163 -0
- package/src/components/scroll-area.tsx +129 -0
- package/src/components/separator.tsx +37 -0
- package/src/components/series-chart-ticks.ts +135 -0
- package/src/components/series-chart.tsx +934 -67
- package/src/components/skeleton.tsx +65 -0
- package/src/components/stat-tile.tsx +160 -27
- package/src/components/table.tsx +263 -0
- package/src/components/toggle-legend.tsx +4 -3
- package/src/components/tree-view.tsx +589 -0
- package/src/components/treemap.tsx +13 -2
- package/src/components/ui.tsx +270 -39
- package/src/data-table.ts +2 -0
- package/src/hooks/use-copy-to-clipboard.ts +122 -0
- package/src/hooks/use-debounce.ts +105 -0
- package/src/hooks/use-file-drop.ts +123 -0
- package/src/i18n/defaults.ts +6 -0
- package/src/i18n/kit-labels.tsx +19 -0
- package/src/i18n/locales/de-CH-informal.ts +10 -0
- package/src/i18n/locales/de-CH.ts +1 -16
- package/src/i18n/locales/de-informal.ts +61 -0
- package/src/i18n/locales/de.ts +25 -0
- package/src/i18n/locales/es.ts +25 -0
- package/src/i18n/locales/fr.ts +25 -0
- package/src/i18n/locales/hu.ts +25 -0
- package/src/i18n/locales/it.ts +25 -0
- package/src/i18n/locales/zh.ts +25 -0
- package/src/i18n/swiss.ts +25 -0
- package/src/index.ts +29 -0
- package/src/lib/dates.ts +48 -0
- package/src/search/command-palette.tsx +29 -3
- package/src/wizard/stepper-nav.tsx +102 -28
- package/src/wizard/types.ts +106 -4
- package/src/wizard/use-wizard.ts +211 -31
- package/src/wizard/wizard-summary.tsx +24 -12
- package/src/wizard.ts +3 -2
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import type { ButtonHTMLAttributes, CSSProperties, KeyboardEvent, ReactNode, Ref } from "react";
|
|
4
|
+
import { X } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
import { cn } from "../lib/cn";
|
|
7
|
+
import { dirOf, type Direction } from "../lib/direction";
|
|
8
|
+
import { useKitLabels } from "../i18n/kit-labels";
|
|
9
|
+
|
|
10
|
+
export interface FloatingPanelLabels {
|
|
11
|
+
/** Accessible name of the panel header's X. */
|
|
12
|
+
close: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_FLOATING_PANEL_LABELS: FloatingPanelLabels = { close: "Close" };
|
|
16
|
+
|
|
17
|
+
/** A LOGICAL corner: `bottom-end` is bottom-right in LTR and bottom-left in RTL. */
|
|
18
|
+
export type FloatingCorner = "bottom-end" | "bottom-start";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* How far off the bottom edge anything floating here sits: clear of `AppShell`'s phone
|
|
22
|
+
* nav (`--app-nav-h`, `0px` above `md` where the nav is not rendered) and of the home
|
|
23
|
+
* indicator, whichever reaches higher — the nav does not pad itself for the inset, so
|
|
24
|
+
* with no nav on screen the inset is all there is to clear. `max`, not a sum: where the
|
|
25
|
+
* nav does cover the inset, adding both would float the button a nav-height too high.
|
|
26
|
+
*/
|
|
27
|
+
const BOTTOM_CLEARANCE = "max(var(--app-nav-h, 0px), env(safe-area-inset-bottom, 0px))";
|
|
28
|
+
/** The side gutter, widened for a landscape notch. Both insets, because a logical
|
|
29
|
+
* side has no one physical inset to name. */
|
|
30
|
+
const SIDE_GUTTER = "max(1rem, env(safe-area-inset-left, 0px), env(safe-area-inset-right, 0px))";
|
|
31
|
+
|
|
32
|
+
function cornerStyle(corner: FloatingCorner, lift: string): CSSProperties {
|
|
33
|
+
return {
|
|
34
|
+
bottom: `calc(${BOTTOM_CLEARANCE} + ${lift})`,
|
|
35
|
+
// Logical properties, so the corner follows the reading direction with no JS. The
|
|
36
|
+
// portal carries the `dir` across (see `usePortalDir`).
|
|
37
|
+
[corner === "bottom-start" ? "insetInlineStart" : "insetInlineEnd"]: SIDE_GUTTER,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The reading direction where the component was RENDERED, for its portalled root: a
|
|
43
|
+
* portal to `<body>` leaves the subtree whose `dir` it inherited, and a logical
|
|
44
|
+
* `inset-inline-end` resolves against the portal's own direction. Read off a hidden
|
|
45
|
+
* inline marker in a layout effect, so the first painted frame already has it.
|
|
46
|
+
*/
|
|
47
|
+
function usePortalDir(): [Ref<HTMLSpanElement>, Direction | undefined] {
|
|
48
|
+
const marker = useRef<HTMLSpanElement>(null);
|
|
49
|
+
const [dir, setDir] = useState<Direction | undefined>(undefined);
|
|
50
|
+
useLayoutEffect(() => {
|
|
51
|
+
setDir(dirOf(marker.current));
|
|
52
|
+
}, []);
|
|
53
|
+
return [marker, dir];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface FloatingActionButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
|
|
57
|
+
/** The accessible name — the button shows only its icon. */
|
|
58
|
+
label: string;
|
|
59
|
+
icon: ReactNode;
|
|
60
|
+
/** Default `bottom-end`. */
|
|
61
|
+
corner?: FloatingCorner;
|
|
62
|
+
/**
|
|
63
|
+
* Distance above the nav / home indicator, as a CSS length. Default `1rem`. Raise it
|
|
64
|
+
* to stack above another floating control in the same corner — keksdose's assistant
|
|
65
|
+
* clears the transactions page's own action group this way (`calc(1rem + 4rem)`).
|
|
66
|
+
*/
|
|
67
|
+
offset?: string;
|
|
68
|
+
ref?: Ref<HTMLButtonElement>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A round, icon-only button pinned to a corner of the viewport — the trigger of a
|
|
73
|
+
* {@link FloatingPanel}, and usable alone for a page's one primary action.
|
|
74
|
+
*
|
|
75
|
+
* keksdose hand-placed each of its floating controls: the assistant launcher at
|
|
76
|
+
* `fixed bottom-36 right-4 md:bottom-6`, the feedback page's and the transactions
|
|
77
|
+
* page's action groups at `right-4 bottom-20`. Every one of those numbers is a guess
|
|
78
|
+
* at the phone nav's height plus whatever else floats there (Keksdose live #314 found
|
|
79
|
+
* out what such a guess is worth for a footer), `right` rather than `end` puts it over
|
|
80
|
+
* the page's leading edge in RTL, and none allows for the home indicator. Here the
|
|
81
|
+
* base is `--app-nav-h` and the safe-area inset, the corner is logical, and
|
|
82
|
+
* {@link FloatingActionButtonProps.offset} is the one number left to the caller — the
|
|
83
|
+
* height of whatever else it has to clear, not the nav's.
|
|
84
|
+
*
|
|
85
|
+
* Portalled, because `position: fixed` inside any transformed ancestor (an animated
|
|
86
|
+
* page transition) is fixed to that ancestor, not to the screen.
|
|
87
|
+
*/
|
|
88
|
+
export function FloatingActionButton({
|
|
89
|
+
label,
|
|
90
|
+
icon,
|
|
91
|
+
corner = "bottom-end",
|
|
92
|
+
offset = "1rem",
|
|
93
|
+
className,
|
|
94
|
+
style,
|
|
95
|
+
type = "button",
|
|
96
|
+
...rest
|
|
97
|
+
}: FloatingActionButtonProps) {
|
|
98
|
+
const [marker, dir] = usePortalDir();
|
|
99
|
+
return (
|
|
100
|
+
<>
|
|
101
|
+
<span ref={marker} hidden />
|
|
102
|
+
{typeof document !== "undefined" &&
|
|
103
|
+
createPortal(
|
|
104
|
+
<button
|
|
105
|
+
{...rest}
|
|
106
|
+
type={type}
|
|
107
|
+
dir={dir}
|
|
108
|
+
aria-label={label}
|
|
109
|
+
title={rest.title ?? label}
|
|
110
|
+
style={{ ...cornerStyle(corner, offset), ...style }}
|
|
111
|
+
className={cn(
|
|
112
|
+
"fixed z-40 inline-flex size-12 items-center justify-center rounded-full",
|
|
113
|
+
"bg-[var(--brand)] text-[var(--brand-contrast)] shadow-lg transition-colors hover:bg-[var(--brand-hover)]",
|
|
114
|
+
"outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)] focus-visible:ring-offset-2",
|
|
115
|
+
"[&_svg]:size-6",
|
|
116
|
+
className,
|
|
117
|
+
)}
|
|
118
|
+
>
|
|
119
|
+
{icon}
|
|
120
|
+
</button>,
|
|
121
|
+
document.body,
|
|
122
|
+
)}
|
|
123
|
+
</>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface FloatingPanelProps {
|
|
128
|
+
/** The panel's heading, and its accessible name. */
|
|
129
|
+
title: ReactNode;
|
|
130
|
+
/** The trigger's accessible name ("Assistant", "Send feedback"). */
|
|
131
|
+
fabLabel: string;
|
|
132
|
+
fabIcon: ReactNode;
|
|
133
|
+
/** Controlled open state. Leave it out to let the panel own it. */
|
|
134
|
+
open?: boolean;
|
|
135
|
+
/** Uncontrolled initial state. Default closed. */
|
|
136
|
+
defaultOpen?: boolean;
|
|
137
|
+
/** Every request to open or close — the FAB, the X, Escape. */
|
|
138
|
+
onOpenChange?: (open: boolean) => void;
|
|
139
|
+
/** Default `bottom-end`. */
|
|
140
|
+
corner?: FloatingCorner;
|
|
141
|
+
/** The FAB's distance above the nav — see {@link FloatingActionButtonProps.offset}.
|
|
142
|
+
* From `md` up the card sits above the FAB, so it moves with it. */
|
|
143
|
+
offset?: string;
|
|
144
|
+
/** Default: `floatingPanel.close` from the {@link UiKitProvider}, else "Close". */
|
|
145
|
+
closeLabel?: string;
|
|
146
|
+
/**
|
|
147
|
+
* Where focus goes on open. Default the panel itself, so a phone's keyboard does not
|
|
148
|
+
* rise over the sheet before the user has asked to type; pass the chat field for a
|
|
149
|
+
* panel whose one job is typing.
|
|
150
|
+
*/
|
|
151
|
+
initialFocus?: () => HTMLElement | null;
|
|
152
|
+
/** Extra classes for the panel. */
|
|
153
|
+
className?: string;
|
|
154
|
+
/** Extra classes for the scrolling body. */
|
|
155
|
+
bodyClassName?: string;
|
|
156
|
+
children: ReactNode;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A docked, NON-MODAL panel in a corner of the screen, opened by a
|
|
161
|
+
* {@link FloatingActionButton} — keksdose's assistant launcher and its feedback page,
|
|
162
|
+
* which each built one.
|
|
163
|
+
*
|
|
164
|
+
* ## Not a Modal
|
|
165
|
+
*
|
|
166
|
+
* The page stays usable while it is open: that is the point of an assistant beside the
|
|
167
|
+
* data it is asked about. So there is no backdrop, no scroll lock, no `aria-modal` and
|
|
168
|
+
* no focus trap — Tab walks out of the panel into the page and back, and a click on the
|
|
169
|
+
* page does not close it. It is `role="dialog"` without `aria-modal`, which is what
|
|
170
|
+
* ARIA calls a non-modal dialog, named by its heading.
|
|
171
|
+
*
|
|
172
|
+
* What it keeps from a dialog is the focus HANDOFF: opening moves focus into it (a
|
|
173
|
+
* keyboard user who pressed the FAB should not have to hunt for what appeared), and
|
|
174
|
+
* closing gives it back to the FAB — but only when focus was in the panel, so a panel
|
|
175
|
+
* closed from the page (a controlled owner) never pulls focus out of the field the
|
|
176
|
+
* user is typing in. Escape closes it while focus is inside, and only then: on the
|
|
177
|
+
* panel element, not the document, so Escape in the page keeps meaning whatever the
|
|
178
|
+
* page means by it.
|
|
179
|
+
*
|
|
180
|
+
* ## Layout
|
|
181
|
+
*
|
|
182
|
+
* From `md` up, a card above the FAB in its corner. Below `md`, a full-width sheet
|
|
183
|
+
* docked to the bottom, sitting ON the `AppShell` bottom nav (`--app-nav-h`) and
|
|
184
|
+
* clear of the home indicator. Pure CSS — no width is measured in JavaScript.
|
|
185
|
+
*
|
|
186
|
+
* The FAB carries `aria-expanded` and, while the panel exists, `aria-controls`. The
|
|
187
|
+
* panel unmounts when closed; an app that must keep a draft across a close keeps it in
|
|
188
|
+
* its own state, where it also survives a reload.
|
|
189
|
+
*/
|
|
190
|
+
export function FloatingPanel({
|
|
191
|
+
title,
|
|
192
|
+
fabLabel,
|
|
193
|
+
fabIcon,
|
|
194
|
+
open: openProp,
|
|
195
|
+
defaultOpen = false,
|
|
196
|
+
onOpenChange,
|
|
197
|
+
corner = "bottom-end",
|
|
198
|
+
offset = "1rem",
|
|
199
|
+
closeLabel,
|
|
200
|
+
initialFocus,
|
|
201
|
+
className,
|
|
202
|
+
bodyClassName,
|
|
203
|
+
children,
|
|
204
|
+
}: FloatingPanelProps) {
|
|
205
|
+
const labels = useKitLabels(
|
|
206
|
+
"floatingPanel",
|
|
207
|
+
DEFAULT_FLOATING_PANEL_LABELS,
|
|
208
|
+
closeLabel === undefined ? undefined : { close: closeLabel },
|
|
209
|
+
);
|
|
210
|
+
const [ownOpen, setOwnOpen] = useState(defaultOpen);
|
|
211
|
+
const open = openProp ?? ownOpen;
|
|
212
|
+
const setOpen = (next: boolean) => {
|
|
213
|
+
if (openProp === undefined) setOwnOpen(next);
|
|
214
|
+
onOpenChange?.(next);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const uid = useId();
|
|
218
|
+
const panelId = `${uid}-panel`;
|
|
219
|
+
const titleId = `${uid}-title`;
|
|
220
|
+
const fabRef = useRef<HTMLButtonElement>(null);
|
|
221
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
222
|
+
const [marker, dir] = usePortalDir();
|
|
223
|
+
|
|
224
|
+
const initialFocusRef = useRef(initialFocus);
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
initialFocusRef.current = initialFocus;
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Focus follows the open/closed TRANSITION, never the mount: a panel that renders
|
|
230
|
+
// open (`defaultOpen`, a restored route) does not take the page's focus by existing.
|
|
231
|
+
const wasOpen = useRef(open);
|
|
232
|
+
// Whether focus was inside the panel when it last changed — read at close time, when
|
|
233
|
+
// the panel (and whatever had focus in it) is already gone.
|
|
234
|
+
const focusInside = useRef(false);
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
const changed = wasOpen.current !== open;
|
|
237
|
+
wasOpen.current = open;
|
|
238
|
+
if (!changed) return;
|
|
239
|
+
if (open) {
|
|
240
|
+
(initialFocusRef.current?.() ?? panelRef.current)?.focus();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const active = document.activeElement;
|
|
244
|
+
const lost = active === null || active === document.body;
|
|
245
|
+
if (focusInside.current || lost) fabRef.current?.focus();
|
|
246
|
+
focusInside.current = false;
|
|
247
|
+
}, [open]);
|
|
248
|
+
|
|
249
|
+
const onPanelKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
|
250
|
+
if (e.key !== "Escape" || e.defaultPrevented) return;
|
|
251
|
+
// Stopped here, so an overlay underneath with a document-level Escape listener does
|
|
252
|
+
// not close too — the innermost thing that has focus is the one that closes.
|
|
253
|
+
e.stopPropagation();
|
|
254
|
+
focusInside.current = true;
|
|
255
|
+
setOpen(false);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
return (
|
|
259
|
+
<>
|
|
260
|
+
<span ref={marker} hidden />
|
|
261
|
+
<FloatingActionButton
|
|
262
|
+
ref={fabRef}
|
|
263
|
+
label={fabLabel}
|
|
264
|
+
icon={fabIcon}
|
|
265
|
+
corner={corner}
|
|
266
|
+
offset={offset}
|
|
267
|
+
aria-expanded={open}
|
|
268
|
+
aria-controls={open ? panelId : undefined}
|
|
269
|
+
onClick={() => {
|
|
270
|
+
// A press ON the FAB closing the panel: the FAB keeps focus by being clicked,
|
|
271
|
+
// nothing to hand back.
|
|
272
|
+
focusInside.current = false;
|
|
273
|
+
setOpen(!open);
|
|
274
|
+
}}
|
|
275
|
+
/>
|
|
276
|
+
{open &&
|
|
277
|
+
typeof document !== "undefined" &&
|
|
278
|
+
createPortal(
|
|
279
|
+
// A container for Escape bubbling up from anything inside it, as a dialog is.
|
|
280
|
+
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
|
|
281
|
+
<div
|
|
282
|
+
ref={panelRef}
|
|
283
|
+
id={panelId}
|
|
284
|
+
role="dialog"
|
|
285
|
+
aria-labelledby={titleId}
|
|
286
|
+
tabIndex={-1}
|
|
287
|
+
dir={dir}
|
|
288
|
+
data-corner={corner}
|
|
289
|
+
onKeyDown={onPanelKeyDown}
|
|
290
|
+
onFocus={() => {
|
|
291
|
+
focusInside.current = true;
|
|
292
|
+
}}
|
|
293
|
+
onBlur={(e) => {
|
|
294
|
+
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) focusInside.current = false;
|
|
295
|
+
}}
|
|
296
|
+
// The phone's sheet sits on the nav; from `md` up the card floats above the
|
|
297
|
+
// FAB (its offset + the 3rem button + a 0.75rem gap).
|
|
298
|
+
style={
|
|
299
|
+
{
|
|
300
|
+
"--fp-bottom": `calc(${BOTTOM_CLEARANCE})`,
|
|
301
|
+
"--fp-bottom-md": `calc(${BOTTOM_CLEARANCE} + ${offset} + 3.75rem)`,
|
|
302
|
+
"--fp-side": SIDE_GUTTER,
|
|
303
|
+
} as CSSProperties
|
|
304
|
+
}
|
|
305
|
+
className={cn(
|
|
306
|
+
"animate-sheet fixed z-40 flex flex-col overflow-hidden outline-none",
|
|
307
|
+
"border border-[var(--border)] bg-[var(--bg-surface)] shadow-2xl",
|
|
308
|
+
// Phone: full width, docked, rounded only where it meets the page.
|
|
309
|
+
"inset-x-0 bottom-[var(--fp-bottom)] max-h-[min(85dvh,calc(100dvh_-_var(--fp-bottom)_-_1rem))] rounded-t-xl border-x-0 border-b-0",
|
|
310
|
+
"pb-[env(safe-area-inset-bottom,0px)] md:pb-0",
|
|
311
|
+
// md+: a card in the corner, above the FAB.
|
|
312
|
+
"md:inset-x-auto md:bottom-[var(--fp-bottom-md)] md:w-[24rem] md:max-h-[min(36rem,calc(100dvh_-_var(--fp-bottom-md)_-_1rem))] md:rounded-xl md:border",
|
|
313
|
+
corner === "bottom-start" ? "md:start-[var(--fp-side)]" : "md:end-[var(--fp-side)]",
|
|
314
|
+
className,
|
|
315
|
+
)}
|
|
316
|
+
>
|
|
317
|
+
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-[var(--border)] px-4 py-3">
|
|
318
|
+
<h2 id={titleId} className="min-w-0 truncate text-base font-semibold text-[var(--text-primary)]">
|
|
319
|
+
{title}
|
|
320
|
+
</h2>
|
|
321
|
+
<button
|
|
322
|
+
type="button"
|
|
323
|
+
onClick={() => {
|
|
324
|
+
focusInside.current = true;
|
|
325
|
+
setOpen(false);
|
|
326
|
+
}}
|
|
327
|
+
aria-label={labels.close}
|
|
328
|
+
className="-me-1.5 shrink-0 rounded p-1.5 text-[var(--text-muted)] outline-none hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)] focus-visible:ring-2 focus-visible:ring-[var(--brand)]"
|
|
329
|
+
>
|
|
330
|
+
<X aria-hidden className="size-5" />
|
|
331
|
+
</button>
|
|
332
|
+
</div>
|
|
333
|
+
<div className={cn("min-h-0 flex-1 overflow-y-auto overscroll-contain p-4", bodyClassName)}>
|
|
334
|
+
{children}
|
|
335
|
+
</div>
|
|
336
|
+
</div>,
|
|
337
|
+
document.body,
|
|
338
|
+
)}
|
|
339
|
+
</>
|
|
340
|
+
);
|
|
341
|
+
}
|
|
@@ -43,6 +43,17 @@ export interface FullBleedDialogProps extends ComponentPropsWithoutRef<"div"> {
|
|
|
43
43
|
backCloses?: boolean;
|
|
44
44
|
/** Extra classes for the PANEL (not the backdrop). */
|
|
45
45
|
className?: string;
|
|
46
|
+
/**
|
|
47
|
+
* A row pinned under the scrolling body — an Apply, a Save. Keksdose's `RangeSheet`
|
|
48
|
+
* (reports/report-range-field.tsx) is a hand-rolled copy of this dialog for want of
|
|
49
|
+
* exactly this slot: a date range picked on a phone is committed by a sticky Apply,
|
|
50
|
+
* and a button at the end of the body scrolls away with the calendar.
|
|
51
|
+
*
|
|
52
|
+
* Outside the scroller, so it stays put without `position: sticky` (which would
|
|
53
|
+
* need the body to be the scroll container of its own child and paint over the
|
|
54
|
+
* last field); padded for the home indicator, since it is the panel's bottom edge.
|
|
55
|
+
*/
|
|
56
|
+
footer?: ReactNode;
|
|
46
57
|
}
|
|
47
58
|
|
|
48
59
|
/**
|
|
@@ -81,6 +92,10 @@ export function FullBleedDialog({
|
|
|
81
92
|
children,
|
|
82
93
|
backCloses = true,
|
|
83
94
|
className,
|
|
95
|
+
footer,
|
|
96
|
+
onMouseDown,
|
|
97
|
+
onMouseUp,
|
|
98
|
+
onKeyDown,
|
|
84
99
|
...rest
|
|
85
100
|
}: FullBleedDialogProps) {
|
|
86
101
|
// Every way OUT goes through `requestClose`, so the panel lowers itself before the
|
|
@@ -107,7 +122,15 @@ export function FullBleedDialog({
|
|
|
107
122
|
useFocusTrap(dialogRef, { active: open });
|
|
108
123
|
|
|
109
124
|
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
|
110
|
-
|
|
125
|
+
// The caller's handler first (keksdose's transaction-search keeps its list keys on
|
|
126
|
+
// the dialog); a `preventDefault()` IN THAT HANDLER claims the key, Escape included.
|
|
127
|
+
// Only there: a descendant's preventDefault does not count. A SearchField in the
|
|
128
|
+
// header prevents Escape to stop Chromium clearing a type=search input, and in
|
|
129
|
+
// 0.8.0 that silently stopped Escape from closing the dialog (keksdose).
|
|
130
|
+
const preventedBefore = e.defaultPrevented;
|
|
131
|
+
onKeyDown?.(e);
|
|
132
|
+
const claimedByCaller = !preventedBefore && e.defaultPrevented;
|
|
133
|
+
if (claimedByCaller || e.key !== "Escape") return;
|
|
111
134
|
// On the panel, the way `Modal` does it, and NOT through `useEscapeKey`: this
|
|
112
135
|
// dialog is the thing a `PickerSheet` opens on top of, and a document-level
|
|
113
136
|
// listener cannot tell which of the two the user meant. One press would dismiss
|
|
@@ -148,7 +171,21 @@ export function FullBleedDialog({
|
|
|
148
171
|
tabIndex={-1}
|
|
149
172
|
ref={dialogRef}
|
|
150
173
|
onKeyDown={handleKeyDown}
|
|
151
|
-
|
|
174
|
+
// COMPOSED with the backdrop's, not overridden by them. These two used to ride
|
|
175
|
+
// `...rest` and then lose to `{...backdropClose}` spread after it, silently —
|
|
176
|
+
// and the one caller about to pass one is keksdose's RangeSheet, whose
|
|
177
|
+
// `onMouseDown={(e) => e.stopPropagation()}` is its dev#477 fix: a press in a
|
|
178
|
+
// portalled sheet must not reach a document-level "outside click" listener,
|
|
179
|
+
// which would unmount the day cell before its `click`. The caller's runs first;
|
|
180
|
+
// the backdrop's still runs after it, since it is the same element.
|
|
181
|
+
onMouseDown={(e) => {
|
|
182
|
+
onMouseDown?.(e);
|
|
183
|
+
backdropClose.onMouseDown(e);
|
|
184
|
+
}}
|
|
185
|
+
onMouseUp={(e) => {
|
|
186
|
+
onMouseUp?.(e);
|
|
187
|
+
backdropClose.onMouseUp(e);
|
|
188
|
+
}}
|
|
152
189
|
>
|
|
153
190
|
<div
|
|
154
191
|
className={cn(
|
|
@@ -158,12 +195,12 @@ export function FullBleedDialog({
|
|
|
158
195
|
)}
|
|
159
196
|
>
|
|
160
197
|
<div className="flex items-center justify-between gap-2 border-b border-[var(--border)] px-3 py-3">
|
|
161
|
-
<div className="min-w-0 font-medium">{header}</div>
|
|
198
|
+
<div className="min-w-0 flex-1 font-medium">{header}</div>
|
|
162
199
|
<button
|
|
163
200
|
type="button"
|
|
164
201
|
onClick={requestClose}
|
|
165
202
|
aria-label={closeLabel}
|
|
166
|
-
className="-
|
|
203
|
+
className="-me-1 shrink-0 rounded p-1.5 text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]"
|
|
167
204
|
>
|
|
168
205
|
<X className="size-5" />
|
|
169
206
|
</button>
|
|
@@ -171,7 +208,18 @@ export function FullBleedDialog({
|
|
|
171
208
|
{/* px-3, not px-4: every pixel of chrome here is width the form fields lose
|
|
172
209
|
on a phone (feedback #32). The body is the only thing that scrolls, so the
|
|
173
210
|
header stays put and the page underneath cannot move at all. */}
|
|
174
|
-
|
|
211
|
+
{/* `min-h-0` so it is the BODY that gives way to a footer, not the footer that
|
|
212
|
+
is pushed off the bottom edge. */}
|
|
213
|
+
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3">{children}</div>
|
|
214
|
+
{footer !== undefined && footer !== null && footer !== false && (
|
|
215
|
+
<div
|
|
216
|
+
data-full-bleed-footer=""
|
|
217
|
+
className="flex shrink-0 flex-wrap items-center justify-end gap-2 border-t border-[var(--border)] px-3 pt-3"
|
|
218
|
+
style={{ paddingBottom: "max(0.75rem, env(safe-area-inset-bottom))" }}
|
|
219
|
+
>
|
|
220
|
+
{footer}
|
|
221
|
+
</div>
|
|
222
|
+
)}
|
|
175
223
|
</div>
|
|
176
224
|
</div>,
|
|
177
225
|
document.body,
|
package/src/components/modal.tsx
CHANGED
|
@@ -117,9 +117,13 @@ export function useBackdropClose(onClose: () => void) {
|
|
|
117
117
|
* where a `data-tour` anchor, a test id, an `aria-describedby` or an `aria-label` has
|
|
118
118
|
* to land; before this, a closed prop list dropped all four silently (audit §api-design).
|
|
119
119
|
*/
|
|
120
|
-
export interface ModalProps extends ComponentPropsWithoutRef<"div"> {
|
|
120
|
+
export interface ModalProps extends Omit<ComponentPropsWithoutRef<"div">, "role"> {
|
|
121
121
|
/** Invoked on backdrop click and on Escape. */
|
|
122
122
|
onClose: () => void;
|
|
123
|
+
/** `"alertdialog"` for a dialog that interrupts to ask a question the user must
|
|
124
|
+
* answer (a confirmation) — screen readers announce it with more urgency. Only the
|
|
125
|
+
* two dialog roles are allowed: the panel stays modal either way. */
|
|
126
|
+
role?: "dialog" | "alertdialog";
|
|
123
127
|
children: ReactNode;
|
|
124
128
|
/**
|
|
125
129
|
* Panel max-width: `md` (28rem), `lg` (32rem), `xl` (48rem).
|
|
@@ -187,6 +191,7 @@ export function Modal({
|
|
|
187
191
|
fullBleed,
|
|
188
192
|
draggable,
|
|
189
193
|
style,
|
|
194
|
+
role = "dialog",
|
|
190
195
|
...rest
|
|
191
196
|
}: ModalProps) {
|
|
192
197
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
@@ -252,7 +257,9 @@ export function Modal({
|
|
|
252
257
|
// walks straight out of) shows up nowhere near the call site that caused it.
|
|
253
258
|
{...rest}
|
|
254
259
|
ref={panelRef}
|
|
255
|
-
role="
|
|
260
|
+
// Normalised at runtime too: an untyped caller's `role="presentation"` must not
|
|
261
|
+
// take the dialog role away (props-passthrough pins that).
|
|
262
|
+
role={role === "alertdialog" ? "alertdialog" : "dialog"}
|
|
256
263
|
aria-modal="true"
|
|
257
264
|
aria-labelledby={labelledBy}
|
|
258
265
|
tabIndex={-1}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { useId } from "react";
|
|
2
|
+
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLocale } from "../i18n/kit-labels";
|
|
5
|
+
|
|
6
|
+
export type ProgressBarTone = "brand" | "neutral" | "success" | "warning" | "danger" | "info";
|
|
7
|
+
export type ProgressBarSize = "sm" | "md" | "lg";
|
|
8
|
+
|
|
9
|
+
const FILL: Record<ProgressBarTone, string> = {
|
|
10
|
+
brand: "bg-[var(--brand)]",
|
|
11
|
+
neutral: "bg-[var(--text-muted)]",
|
|
12
|
+
success: "bg-[var(--success)]",
|
|
13
|
+
warning: "bg-[var(--warning)]",
|
|
14
|
+
danger: "bg-[var(--danger)]",
|
|
15
|
+
info: "bg-[var(--info)]",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const TRACK_HEIGHT: Record<ProgressBarSize, string> = { sm: "h-1", md: "h-2", lg: "h-3" };
|
|
19
|
+
|
|
20
|
+
export interface ProgressBarProps extends Omit<ComponentPropsWithoutRef<"div">, "children" | "role"> {
|
|
21
|
+
/** Where it stands. Leave it undefined for an INDETERMINATE bar — work is under way
|
|
22
|
+
* and nobody knows how much is left (an import that reports no count). */
|
|
23
|
+
value?: number;
|
|
24
|
+
min?: number;
|
|
25
|
+
max?: number;
|
|
26
|
+
/**
|
|
27
|
+
* `progress` (default): a task moving toward completion — `role="progressbar"`.
|
|
28
|
+
* `meter`: a share of a known whole that is not going anywhere (budget used, disk
|
|
29
|
+
* space, one category's slice of spending) — `role="meter"`. keksdose's share bars
|
|
30
|
+
* were progress bars to a screen reader, which reads "busy" into a figure that is
|
|
31
|
+
* simply a figure. A meter is never indeterminate; a missing value reads as `min`.
|
|
32
|
+
*/
|
|
33
|
+
variant?: "progress" | "meter";
|
|
34
|
+
/** Visible label above the bar, and its accessible name. Without it, pass
|
|
35
|
+
* `aria-label` (an indeterminate bar with neither is named `common.loading`). */
|
|
36
|
+
label?: ReactNode;
|
|
37
|
+
/** Show the value text at the end of the label row. */
|
|
38
|
+
showValue?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* The value as words — both the visible `showValue` text and `aria-valuetext`.
|
|
41
|
+
* Default: the fraction as a percentage in the kit's locale ("42 %" in French).
|
|
42
|
+
* Pass one for anything that is not a percentage: `(v, max) => \`${v} of ${max} files\``.
|
|
43
|
+
*/
|
|
44
|
+
formatValue?: (value: number, max: number, min: number) => string;
|
|
45
|
+
tone?: ProgressBarTone;
|
|
46
|
+
size?: ProgressBarSize;
|
|
47
|
+
locale?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A horizontal bar that fills toward `max`.
|
|
52
|
+
*
|
|
53
|
+
* keksdose (import progress, budget share bars) and kastlan (upload and job
|
|
54
|
+
* progress) each drew their own div-in-a-div. None had a role, so a reader heard
|
|
55
|
+
* nothing at all; the share bars that did have one claimed to be progress.
|
|
56
|
+
*
|
|
57
|
+
* `aria-valuetext` is always set when there is a value: a bare `aria-valuenow="37"`
|
|
58
|
+
* is read as "37" by some readers and "37 percent" by others, and neither is right
|
|
59
|
+
* when `max` is 212 files.
|
|
60
|
+
*
|
|
61
|
+
* The indeterminate sweep stops under `prefers-reduced-motion`. It becomes a still,
|
|
62
|
+
* half-strength bar across the whole track — still visibly "something is happening",
|
|
63
|
+
* with nothing moving. The sweep is a Tailwind arbitrary `animate-[…]` rather than
|
|
64
|
+
* tokens.css's `.animate-indeterminate` class because that one is UNLAYERED, and an
|
|
65
|
+
* unlayered rule beats every layered utility, `motion-reduce:animate-none` included.
|
|
66
|
+
*/
|
|
67
|
+
export function ProgressBar({
|
|
68
|
+
value,
|
|
69
|
+
min = 0,
|
|
70
|
+
max = 100,
|
|
71
|
+
variant = "progress",
|
|
72
|
+
label,
|
|
73
|
+
showValue = false,
|
|
74
|
+
formatValue,
|
|
75
|
+
tone = "brand",
|
|
76
|
+
size = "md",
|
|
77
|
+
locale,
|
|
78
|
+
className,
|
|
79
|
+
"aria-label": ariaLabel,
|
|
80
|
+
"aria-labelledby": ariaLabelledBy,
|
|
81
|
+
...rest
|
|
82
|
+
}: ProgressBarProps) {
|
|
83
|
+
const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
|
|
84
|
+
const kitLocale = useKitLocale(locale);
|
|
85
|
+
const labelId = useId();
|
|
86
|
+
|
|
87
|
+
const meter = variant === "meter";
|
|
88
|
+
const current = value === undefined || !Number.isFinite(value) ? (meter ? min : undefined) : value;
|
|
89
|
+
const indeterminate = current === undefined;
|
|
90
|
+
const span = max - min;
|
|
91
|
+
const clamped = indeterminate ? undefined : Math.min(max, Math.max(min, current));
|
|
92
|
+
const fraction = clamped === undefined || span <= 0 ? 0 : (clamped - min) / span;
|
|
93
|
+
|
|
94
|
+
const valueText =
|
|
95
|
+
clamped === undefined
|
|
96
|
+
? undefined
|
|
97
|
+
: formatValue
|
|
98
|
+
? formatValue(clamped, max, min)
|
|
99
|
+
: new Intl.NumberFormat(kitLocale, { style: "percent", maximumFractionDigits: 0 }).format(fraction);
|
|
100
|
+
|
|
101
|
+
const labelledBy = ariaLabelledBy ?? (label != null ? labelId : undefined);
|
|
102
|
+
const name = labelledBy ? undefined : (ariaLabel ?? (indeterminate ? common.loading : undefined));
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div className={cn("min-w-0", className)} data-state={indeterminate ? "indeterminate" : "determinate"}>
|
|
106
|
+
{(label != null || (showValue && valueText !== undefined)) && (
|
|
107
|
+
<div className="mb-1 flex items-baseline gap-2 text-sm">
|
|
108
|
+
{label != null && (
|
|
109
|
+
<span id={labelId} className="min-w-0 text-[var(--text-secondary)]">
|
|
110
|
+
{label}
|
|
111
|
+
</span>
|
|
112
|
+
)}
|
|
113
|
+
{showValue && valueText !== undefined && (
|
|
114
|
+
// Hidden: the same words are the bar's aria-valuetext, and reading them
|
|
115
|
+
// twice in a row is noise.
|
|
116
|
+
<span aria-hidden className="ms-auto shrink-0 tabular-nums text-[var(--text-muted)]">
|
|
117
|
+
{valueText}
|
|
118
|
+
</span>
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
)}
|
|
122
|
+
<div
|
|
123
|
+
{...rest}
|
|
124
|
+
role={meter ? "meter" : "progressbar"}
|
|
125
|
+
aria-label={name}
|
|
126
|
+
aria-labelledby={labelledBy}
|
|
127
|
+
aria-valuemin={indeterminate ? undefined : min}
|
|
128
|
+
aria-valuemax={indeterminate ? undefined : max}
|
|
129
|
+
aria-valuenow={clamped}
|
|
130
|
+
aria-valuetext={valueText}
|
|
131
|
+
aria-busy={!meter && indeterminate ? true : undefined}
|
|
132
|
+
// `relative` + `overflow-hidden`: the indeterminate segment is positioned in
|
|
133
|
+
// here and must not paint past the rounded ends.
|
|
134
|
+
className={cn("relative w-full overflow-hidden rounded-full bg-[var(--bg-active)]", TRACK_HEIGHT[size])}
|
|
135
|
+
>
|
|
136
|
+
{indeterminate ? (
|
|
137
|
+
<div
|
|
138
|
+
data-part="fill"
|
|
139
|
+
className={cn(
|
|
140
|
+
// `left-0`, deliberately physical: the keyframes (tokens.css
|
|
141
|
+
// `indeterminate-sweep`) translate along the physical x axis, and under
|
|
142
|
+
// RTL the same keyframes are run in REVERSE — from the right edge back
|
|
143
|
+
// to the left. A logical `start-0` would park the segment at the right
|
|
144
|
+
// in RTL and the reversed sweep would then start off-screen.
|
|
145
|
+
"absolute inset-y-0 left-0 w-1/3 rounded-full",
|
|
146
|
+
"animate-[indeterminate-sweep_1.4s_ease-in-out_infinite] rtl:[animation-direction:reverse]",
|
|
147
|
+
"motion-reduce:w-full motion-reduce:animate-none motion-reduce:opacity-50",
|
|
148
|
+
FILL[tone],
|
|
149
|
+
)}
|
|
150
|
+
/>
|
|
151
|
+
) : (
|
|
152
|
+
<div
|
|
153
|
+
data-part="fill"
|
|
154
|
+
// Block flow starts at the inline start, so a plain width fills from the
|
|
155
|
+
// right under `dir="rtl"` with nothing extra.
|
|
156
|
+
className={cn("h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none", FILL[tone])}
|
|
157
|
+
style={{ width: `${fraction * 100}%` }}
|
|
158
|
+
/>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
}
|