@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
|
@@ -42,6 +42,15 @@ export interface DialogFrameProps extends Omit<ModalProps, "labelledBy" | "child
|
|
|
42
42
|
* calling `onClose` directly unmounts it at once.
|
|
43
43
|
*/
|
|
44
44
|
actions?: ReactNode | ((close: () => void) => ReactNode);
|
|
45
|
+
/**
|
|
46
|
+
* Controls that belong to the HEADER rather than to the actions row — an "Edit"
|
|
47
|
+
* toggle, a status badge with a menu, a "Copy link" (kastlan). Rendered beside the
|
|
48
|
+
* title, before the optional X; on a phone, where title and controls do not fit on
|
|
49
|
+
* one line, they wrap under the title and the X keeps its corner.
|
|
50
|
+
*
|
|
51
|
+
* A function receives the animated close, as `actions` does.
|
|
52
|
+
*/
|
|
53
|
+
headerActions?: ReactNode | ((close: () => void) => ReactNode);
|
|
45
54
|
/**
|
|
46
55
|
* Show an X in the header. Off by default: a centred dialog has a backdrop and
|
|
47
56
|
* Escape, and a form dialog has a Cancel. On for a dialog that commits as it goes
|
|
@@ -52,8 +61,24 @@ export interface DialogFrameProps extends Omit<ModalProps, "labelledBy" | "child
|
|
|
52
61
|
closeLabel?: string;
|
|
53
62
|
/** Extra classes for the scrolling body (default spacing `space-y-3`). */
|
|
54
63
|
bodyClassName?: string;
|
|
55
|
-
/**
|
|
56
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Extra classes for the header row (default padding `px-4 pt-4 pb-3`), merged last —
|
|
66
|
+
* `px-3` for a phone sheet whose body runs at `px-3`, so heading and fields share
|
|
67
|
+
* one gutter.
|
|
68
|
+
*/
|
|
69
|
+
headerClassName?: string;
|
|
70
|
+
/**
|
|
71
|
+
* A rule under the header, the twin of the one over the actions row — for a tall or
|
|
72
|
+
* full-screen body that scrolls under a header which stays. The body then starts a
|
|
73
|
+
* step below the rule instead of flush against it.
|
|
74
|
+
*/
|
|
75
|
+
headerDivider?: boolean;
|
|
76
|
+
/**
|
|
77
|
+
* The body. Optional: a dialog whose title, description and actions are the whole
|
|
78
|
+
* of it (a "you have unsynced changes" question) leaves it out, and the frame then
|
|
79
|
+
* renders no body at all rather than an empty padded one.
|
|
80
|
+
*/
|
|
81
|
+
children?: ReactNode;
|
|
57
82
|
}
|
|
58
83
|
|
|
59
84
|
/**
|
|
@@ -92,9 +117,12 @@ export function DialogFrame({
|
|
|
92
117
|
description,
|
|
93
118
|
headingAs: Heading = "h2",
|
|
94
119
|
actions,
|
|
120
|
+
headerActions,
|
|
95
121
|
closeButton = false,
|
|
96
122
|
closeLabel,
|
|
97
123
|
bodyClassName,
|
|
124
|
+
headerClassName,
|
|
125
|
+
headerDivider = false,
|
|
98
126
|
className,
|
|
99
127
|
children,
|
|
100
128
|
"aria-describedby": describedBy,
|
|
@@ -103,6 +131,8 @@ export function DialogFrame({
|
|
|
103
131
|
const titleId = useId();
|
|
104
132
|
const descriptionId = useId();
|
|
105
133
|
const hasDescription = description !== undefined && description !== null;
|
|
134
|
+
// `false` too, so `{cond && <Body/>}` with a false `cond` means "no body".
|
|
135
|
+
const hasBody = children !== undefined && children !== null && children !== false;
|
|
106
136
|
|
|
107
137
|
return (
|
|
108
138
|
<Modal
|
|
@@ -116,30 +146,50 @@ export function DialogFrame({
|
|
|
116
146
|
// to the body, which is the only part that should move.
|
|
117
147
|
className={cn("flex flex-col overflow-hidden p-0", className)}
|
|
118
148
|
>
|
|
119
|
-
<div className="flex shrink-0 items-start justify-between gap-2 px-4 pt-4 pb-3">
|
|
120
|
-
<div className="min-w-0">
|
|
121
|
-
<Heading id={titleId} className="text-lg font-semibold leading-snug text-[var(--text-primary)]">
|
|
122
|
-
{title}
|
|
123
|
-
</Heading>
|
|
124
|
-
{hasDescription && (
|
|
125
|
-
<p id={descriptionId} className="mt-0.5 text-sm text-[var(--text-muted)]">
|
|
126
|
-
{description}
|
|
127
|
-
</p>
|
|
128
|
-
)}
|
|
129
|
-
</div>
|
|
130
|
-
{closeButton && <FrameClose label={closeLabel} onClose={modal.onClose} />}
|
|
131
|
-
</div>
|
|
132
149
|
<div
|
|
133
150
|
className={cn(
|
|
134
|
-
// `
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
151
|
+
// `last:pb-4`: a frame with neither body nor actions closes on its header.
|
|
152
|
+
"flex shrink-0 items-start justify-between gap-2 px-4 pt-4 pb-3 last:pb-4",
|
|
153
|
+
headerDivider && "border-b border-[var(--border)]",
|
|
154
|
+
headerClassName,
|
|
138
155
|
)}
|
|
139
156
|
>
|
|
140
|
-
{
|
|
157
|
+
{/* Title and header actions share a wrapping row of their own, so it is THEY
|
|
158
|
+
that wrap on a phone — the actions drop under a title that needs its 12rem —
|
|
159
|
+
while the X stays outside it, pinned to the top-end corner. */}
|
|
160
|
+
<div className="flex min-w-0 flex-1 flex-wrap items-start justify-between gap-x-3 gap-y-2">
|
|
161
|
+
<div className="min-w-0 grow basis-48">
|
|
162
|
+
<Heading id={titleId} className="text-lg font-semibold leading-snug text-[var(--text-primary)]">
|
|
163
|
+
{title}
|
|
164
|
+
</Heading>
|
|
165
|
+
{hasDescription && (
|
|
166
|
+
<p id={descriptionId} className="mt-0.5 text-sm text-[var(--text-muted)]">
|
|
167
|
+
{description}
|
|
168
|
+
</p>
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
{headerActions !== undefined && headerActions !== null && headerActions !== false && (
|
|
172
|
+
<FrameHeaderActions actions={headerActions} onClose={modal.onClose} />
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
{closeButton && <FrameClose label={closeLabel} onClose={modal.onClose} />}
|
|
141
176
|
</div>
|
|
142
|
-
{
|
|
177
|
+
{hasBody && (
|
|
178
|
+
<div
|
|
179
|
+
className={cn(
|
|
180
|
+
// `min-h-0` is what lets a flex child shrink below its content and scroll;
|
|
181
|
+
// `last:pb-4` closes a frame that has no actions row under it.
|
|
182
|
+
"min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain px-4 pb-3 last:pb-4",
|
|
183
|
+
headerDivider && "pt-3",
|
|
184
|
+
bodyClassName,
|
|
185
|
+
)}
|
|
186
|
+
>
|
|
187
|
+
{children}
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
{actions !== undefined && actions !== null && (
|
|
191
|
+
<FrameActions actions={actions} onClose={modal.onClose} divider={hasBody} />
|
|
192
|
+
)}
|
|
143
193
|
</Modal>
|
|
144
194
|
);
|
|
145
195
|
}
|
|
@@ -161,18 +211,43 @@ function FrameClose({ label, onClose }: { label?: string; onClose: () => void })
|
|
|
161
211
|
);
|
|
162
212
|
}
|
|
163
213
|
|
|
214
|
+
/** The header's own controls; a component for the same reason as {@link FrameClose}. */
|
|
215
|
+
function FrameHeaderActions({
|
|
216
|
+
actions,
|
|
217
|
+
onClose,
|
|
218
|
+
}: {
|
|
219
|
+
actions: NonNullable<DialogFrameProps["headerActions"]>;
|
|
220
|
+
onClose: () => void;
|
|
221
|
+
}) {
|
|
222
|
+
const close = useContext(ModalCloseContext) ?? onClose;
|
|
223
|
+
return (
|
|
224
|
+
<div data-dialog-header-actions="" className="flex shrink-0 flex-wrap items-center gap-2">
|
|
225
|
+
{typeof actions === "function" ? actions(close) : actions}
|
|
226
|
+
</div>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
164
230
|
function FrameActions({
|
|
165
231
|
actions,
|
|
166
232
|
onClose,
|
|
233
|
+
divider,
|
|
167
234
|
}: {
|
|
168
235
|
actions: NonNullable<DialogFrameProps["actions"]>;
|
|
169
236
|
onClose: () => void;
|
|
237
|
+
/** Off when there is no body: nothing scrolls, so there is no edge to mark, and a
|
|
238
|
+
* rule straight under the description would cut the question from its answers. */
|
|
239
|
+
divider: boolean;
|
|
170
240
|
}) {
|
|
171
241
|
const close = useContext(ModalCloseContext) ?? onClose;
|
|
172
242
|
return (
|
|
173
243
|
// The border marks where the scrolling stops; `flex-wrap` keeps three long
|
|
174
244
|
// translated labels on a phone from pushing the row wider than the sheet.
|
|
175
|
-
<div
|
|
245
|
+
<div
|
|
246
|
+
className={cn(
|
|
247
|
+
"flex shrink-0 flex-wrap items-center justify-end gap-2 px-4 py-3",
|
|
248
|
+
divider && "border-t border-[var(--border)]",
|
|
249
|
+
)}
|
|
250
|
+
>
|
|
176
251
|
{typeof actions === "function" ? actions(close) : actions}
|
|
177
252
|
</div>
|
|
178
253
|
);
|
|
@@ -149,7 +149,27 @@ export interface DisclosureProps extends Omit<ComponentPropsWithoutRef<"div">, "
|
|
|
149
149
|
headerClassName?: string;
|
|
150
150
|
/** Extra classes for the body's wrapper — where its padding and spacing live. */
|
|
151
151
|
bodyClassName?: string;
|
|
152
|
-
|
|
152
|
+
/**
|
|
153
|
+
* Content at the far end of the header row, beside the title: a count, a date, a
|
|
154
|
+
* status chip, an action button. It is a SIBLING of the header button, never inside
|
|
155
|
+
* it — a button may not contain another interactive element, and a count inside it
|
|
156
|
+
* would also be read as part of the button's name. The header button still spans
|
|
157
|
+
* the row under it (a stretched hit area), so clicking the empty space or the
|
|
158
|
+
* card's chevron still toggles, while whatever sits in `trailing` gets its own
|
|
159
|
+
* clicks. With `trailing`, the card's chevron moves after it, to the row's end.
|
|
160
|
+
*/
|
|
161
|
+
trailing?: ReactNode;
|
|
162
|
+
/**
|
|
163
|
+
* Trigger-only mode: the id of an element the CALLER renders elsewhere — the hidden
|
|
164
|
+
* rows of a table, a panel in another column — which this header shows and hides.
|
|
165
|
+
* The header's `aria-controls` points at it and the disclosure renders no body of
|
|
166
|
+
* its own (`children`, `bodyClassName` and `keepMounted` are ignored). Pair it with
|
|
167
|
+
* `open` / `onOpenChange`: the caller owns the state, since the caller renders what
|
|
168
|
+
* it governs.
|
|
169
|
+
*/
|
|
170
|
+
controls?: string;
|
|
171
|
+
/** The body. Not rendered in trigger-only mode ({@link DisclosureProps.controls}). */
|
|
172
|
+
children?: ReactNode;
|
|
153
173
|
}
|
|
154
174
|
|
|
155
175
|
/**
|
|
@@ -178,6 +198,8 @@ export function Disclosure({
|
|
|
178
198
|
disabled,
|
|
179
199
|
headerClassName,
|
|
180
200
|
bodyClassName,
|
|
201
|
+
trailing,
|
|
202
|
+
controls,
|
|
181
203
|
className,
|
|
182
204
|
children,
|
|
183
205
|
...rest
|
|
@@ -186,6 +208,10 @@ export function Disclosure({
|
|
|
186
208
|
const open = controlled ?? own;
|
|
187
209
|
const bodyId = useId();
|
|
188
210
|
const card = variant === "card";
|
|
211
|
+
const triggerOnly = controls !== undefined;
|
|
212
|
+
// The card's header squares its lower corners only when a body opens under it.
|
|
213
|
+
const joined = open && !triggerOnly;
|
|
214
|
+
const hasTrailing = trailing !== undefined && trailing !== null && trailing !== false;
|
|
189
215
|
|
|
190
216
|
const toggle = () => {
|
|
191
217
|
const next = !open;
|
|
@@ -197,43 +223,79 @@ export function Disclosure({
|
|
|
197
223
|
<button
|
|
198
224
|
type="button"
|
|
199
225
|
aria-expanded={open}
|
|
200
|
-
aria-controls={bodyId}
|
|
226
|
+
aria-controls={triggerOnly ? controls : bodyId}
|
|
201
227
|
disabled={disabled}
|
|
202
228
|
onClick={toggle}
|
|
229
|
+
data-disclosure-trigger=""
|
|
203
230
|
className={cn(
|
|
204
231
|
"flex w-full gap-2 text-start outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
|
205
|
-
|
|
232
|
+
hasTrailing
|
|
233
|
+
? // Stretched over the whole header row (the row is `relative`), so the space
|
|
234
|
+
// round `trailing` and the card's chevron still toggle; the ring and the
|
|
235
|
+
// card's hover paint on the stretched area / the row, not the text box.
|
|
236
|
+
cn(
|
|
237
|
+
"min-w-0 flex-1 after:absolute after:inset-0 after:content-['']",
|
|
238
|
+
"focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-[var(--brand)]",
|
|
239
|
+
card ? cn("after:rounded-lg", joined && "after:rounded-b-none") : "after:rounded-sm",
|
|
240
|
+
)
|
|
241
|
+
: "focus-visible:ring-2 focus-visible:ring-[var(--brand)]",
|
|
206
242
|
card
|
|
207
243
|
? cn(
|
|
208
|
-
"items-center justify-between rounded-lg p-4
|
|
209
|
-
|
|
244
|
+
"items-center justify-between rounded-lg p-4",
|
|
245
|
+
hasTrailing ? "pe-0" : "hover:bg-[var(--bg-hover)] focus-visible:ring-inset",
|
|
246
|
+
joined && "rounded-b-none",
|
|
210
247
|
)
|
|
211
248
|
: "items-center rounded-sm text-sm font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]",
|
|
212
249
|
headerClassName,
|
|
213
250
|
)}
|
|
214
251
|
>
|
|
215
252
|
{!card && <Chevron open={open} leading />}
|
|
216
|
-
|
|
253
|
+
{/* `flex-1`: the title takes the row, so whatever a caller puts at its end sits
|
|
254
|
+
at the header's far edge. */}
|
|
255
|
+
<span className="min-w-0 flex-1">
|
|
217
256
|
<span className={cn("block", card && "text-sm font-semibold text-[var(--text-primary)]")}>{title}</span>
|
|
218
257
|
{hint !== undefined && (
|
|
219
258
|
<span className="mt-0.5 block text-xs font-normal text-[var(--text-muted)]">{hint}</span>
|
|
220
259
|
)}
|
|
221
260
|
</span>
|
|
222
|
-
{card && <Chevron open={open} />}
|
|
261
|
+
{card && !hasTrailing && <Chevron open={open} />}
|
|
223
262
|
</button>
|
|
224
263
|
);
|
|
225
264
|
|
|
265
|
+
// Preflight already makes h1–h6 inherit size and weight, so the heading adds
|
|
266
|
+
// structure and nothing visible.
|
|
267
|
+
const header = Heading ? <Heading className={hasTrailing ? "min-w-0 flex-1" : undefined}>{button}</Heading> : button;
|
|
268
|
+
|
|
226
269
|
return (
|
|
227
270
|
<div
|
|
228
271
|
{...rest}
|
|
229
272
|
className={cn(card && "rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] shadow-sm", className)}
|
|
230
273
|
>
|
|
231
|
-
{
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
274
|
+
{hasTrailing ? (
|
|
275
|
+
<div
|
|
276
|
+
className={cn(
|
|
277
|
+
"relative flex items-center gap-2",
|
|
278
|
+
card &&
|
|
279
|
+
cn(
|
|
280
|
+
"rounded-lg pe-4 has-[[data-disclosure-trigger]:enabled:hover]:bg-[var(--bg-hover)]",
|
|
281
|
+
joined && "rounded-b-none",
|
|
282
|
+
),
|
|
283
|
+
)}
|
|
284
|
+
>
|
|
285
|
+
{header}
|
|
286
|
+
{/* Positioned, so it paints over the stretched button and takes its own
|
|
287
|
+
clicks; the chevron is not, so a click on it lands on the button. */}
|
|
288
|
+
<div className="relative flex shrink-0 items-center gap-2">{trailing}</div>
|
|
289
|
+
{card && <Chevron open={open} />}
|
|
290
|
+
</div>
|
|
291
|
+
) : (
|
|
292
|
+
header
|
|
293
|
+
)}
|
|
294
|
+
{!triggerOnly && (
|
|
295
|
+
<Collapse id={bodyId} open={open} keepMounted={keepMounted}>
|
|
296
|
+
<div className={cn(card ? "space-y-3 px-4 pb-4" : "space-y-2 pt-2", bodyClassName)}>{children}</div>
|
|
297
|
+
</Collapse>
|
|
298
|
+
)}
|
|
237
299
|
</div>
|
|
238
300
|
);
|
|
239
301
|
}
|
|
@@ -11,6 +11,7 @@ import { useAnchoredPanel } from "../hooks/use-anchored-panel";
|
|
|
11
11
|
import { useEscapeKey, useOutsideClick } from "../hooks/use-dismiss";
|
|
12
12
|
import { useOverlayHistory } from "../hooks/use-overlay-history";
|
|
13
13
|
import { cn } from "../lib/cn";
|
|
14
|
+
import { useAnchorDir } from "./use-anchor-dir";
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Open state + close-on-outside-click for the custom dropdowns (MultiSelect,
|
|
@@ -225,8 +226,12 @@ export interface DropdownPanelProps extends ComponentPropsWithoutRef<"div"> {
|
|
|
225
226
|
panelRef?: RefObject<HTMLDivElement | null>;
|
|
226
227
|
/** Panel width in px, for the anchored form (the CSS `w-*` cannot be measured). */
|
|
227
228
|
width?: number;
|
|
228
|
-
/** Which of the panel's edges lines up with the trigger's, room permitting.
|
|
229
|
-
|
|
229
|
+
/** Which of the panel's edges lines up with the trigger's, room permitting.
|
|
230
|
+
* `start`/`end` follow the trigger's reading direction — `end` is the right edge
|
|
231
|
+
* in a left-to-right form and the left one in a right-to-left form — and are what
|
|
232
|
+
* the kit's own pickers pass; `left`/`right` stay physical. Default `end`, which
|
|
233
|
+
* is the old `right` in a left-to-right page. */
|
|
234
|
+
align?: "left" | "right" | "start" | "end";
|
|
230
235
|
children: ReactNode;
|
|
231
236
|
}
|
|
232
237
|
|
|
@@ -239,7 +244,7 @@ export interface DropdownPanelProps extends ComponentPropsWithoutRef<"div"> {
|
|
|
239
244
|
*
|
|
240
245
|
* Without `anchorRef` the panel is `position: absolute` inside whatever relative box
|
|
241
246
|
* the caller put it in, and the caller places it with `className` (`w-64`,
|
|
242
|
-
* `
|
|
247
|
+
* `end-0 top-full`). That is fine in a page that does not scroll around it, and
|
|
243
248
|
* wrong the moment an ancestor has `overflow` — the panel is then CLIPPED by that
|
|
244
249
|
* ancestor's box, with no error and no scrollbar, just a list with its side sliced
|
|
245
250
|
* off. Keksdose dev#548 is what that looks like in practice: the app's own content
|
|
@@ -266,12 +271,13 @@ export function DropdownPanel({
|
|
|
266
271
|
anchorRef,
|
|
267
272
|
panelRef,
|
|
268
273
|
width = 256,
|
|
269
|
-
align = "
|
|
274
|
+
align = "end",
|
|
270
275
|
children,
|
|
271
276
|
style,
|
|
272
277
|
...rest
|
|
273
278
|
}: DropdownPanelProps) {
|
|
274
279
|
const anchored = useAnchoredPanel(anchorRef ?? EMPTY_REF, Boolean(anchorRef));
|
|
280
|
+
const dir = useAnchorDir(anchorRef ?? EMPTY_REF, Boolean(anchorRef));
|
|
275
281
|
const body = (
|
|
276
282
|
<>
|
|
277
283
|
{header}
|
|
@@ -313,12 +319,18 @@ export function DropdownPanel({
|
|
|
313
319
|
if (!rect) return null;
|
|
314
320
|
// Clamped exactly as `Popover` clamps: the panel may leave the trigger's edge to
|
|
315
321
|
// stay on screen, because a list half off the viewport is the bug this exists for.
|
|
316
|
-
|
|
322
|
+
// The portal leaves the subtree the trigger's `dir` came from, so it is read off
|
|
323
|
+
// the trigger — for the alignment here, and for the panel's own `dir` below,
|
|
324
|
+
// without which a right-to-left form got a left-to-right list.
|
|
325
|
+
const physical =
|
|
326
|
+
align === "start" ? (dir === "rtl" ? "right" : "left") : align === "end" ? (dir === "rtl" ? "left" : "right") : align;
|
|
327
|
+
const wanted = physical === "right" ? rect.right - width : rect.left;
|
|
317
328
|
const left = Math.min(Math.max(8, wanted), window.innerWidth - width - 8);
|
|
318
329
|
return createPortal(
|
|
319
330
|
<div
|
|
320
331
|
{...rest}
|
|
321
332
|
ref={panelRef}
|
|
333
|
+
dir={dir}
|
|
322
334
|
// The caller's `style` is kept underneath, but the placement is measured rather
|
|
323
335
|
// than chosen (dev#548: an `overflow` ancestor clipped a third of this panel
|
|
324
336
|
// away), so those five values are the panel's own.
|
|
@@ -7,20 +7,30 @@ import {
|
|
|
7
7
|
ComboboxPanel,
|
|
8
8
|
useComboboxCore,
|
|
9
9
|
useComboboxFieldError,
|
|
10
|
+
type ComboClearValue,
|
|
10
11
|
type ComboOption,
|
|
11
12
|
} from "./combobox-core";
|
|
12
13
|
import { DEFAULT_COMBOBOX_LABELS, DEFAULT_COMMON_LABELS, useKitLabels } from "../i18n/kit-labels";
|
|
13
14
|
|
|
14
|
-
export type { ComboOption } from "./combobox-core";
|
|
15
|
+
export type { ComboClearValue, ComboOption } from "./combobox-core";
|
|
15
16
|
|
|
16
17
|
/** `onChange` is the kit's — "a selection was made", carrying values — rather
|
|
17
18
|
* than the div's form event, so the DOM's spelling of it is omitted. */
|
|
18
|
-
export interface EntityComboboxProps<V extends string | number>
|
|
19
|
+
export interface EntityComboboxProps<V extends string | number, C extends ComboClearValue = null>
|
|
19
20
|
extends Omit<ComponentPropsWithoutRef<"div">, "onChange"> {
|
|
20
|
-
/** Selected id
|
|
21
|
-
value: V | null | undefined;
|
|
22
|
-
/** Selecting an option emits its value; the clear button emits `
|
|
23
|
-
onChange: (value: V |
|
|
21
|
+
/** Selected id; `null`, `undefined` or `clearValue` when nothing is selected. */
|
|
22
|
+
value: V | C | null | undefined;
|
|
23
|
+
/** Selecting an option emits its value; the clear button emits `clearValue`. */
|
|
24
|
+
onChange: (value: V | C) => void;
|
|
25
|
+
/**
|
|
26
|
+
* What the clear button emits. Default `null`. Pass `""` for a form whose schema
|
|
27
|
+
* wants an empty string for "no choice" (a zod `z.string()` field), rather than
|
|
28
|
+
* mapping `null` in every `onChange` — the type of `onChange` follows it, so the
|
|
29
|
+
* mapping cannot be forgotten on one field and not another. The picker also reads
|
|
30
|
+
* the value back as empty, so `value=""` shows the placeholder and no "×".
|
|
31
|
+
* See {@link ComboClearValue} for why the choice is closed.
|
|
32
|
+
*/
|
|
33
|
+
clearValue?: C;
|
|
24
34
|
/** Already-loaded options (client-side filtered). Also used to resolve the
|
|
25
35
|
* trigger label for the current `value`. */
|
|
26
36
|
options?: ComboOption<V>[];
|
|
@@ -70,9 +80,10 @@ export interface EntityComboboxProps<V extends string | number>
|
|
|
70
80
|
* the shared field/anchor/dismiss/search primitives (no cmdk/Radix). For picking
|
|
71
81
|
* several entities use {@link MultiEntityCombobox}.
|
|
72
82
|
*/
|
|
73
|
-
export function EntityCombobox<V extends string | number>({
|
|
83
|
+
export function EntityCombobox<V extends string | number, C extends ComboClearValue = null>({
|
|
74
84
|
value,
|
|
75
85
|
onChange,
|
|
86
|
+
clearValue = null as C,
|
|
76
87
|
options,
|
|
77
88
|
loadOptions,
|
|
78
89
|
loading,
|
|
@@ -95,7 +106,7 @@ export function EntityCombobox<V extends string | number>({
|
|
|
95
106
|
loadErrorLabel,
|
|
96
107
|
"aria-label": ariaLabel,
|
|
97
108
|
...rest
|
|
98
|
-
}: EntityComboboxProps<V>) {
|
|
109
|
+
}: EntityComboboxProps<V, C>) {
|
|
99
110
|
const core = useComboboxCore<V>({
|
|
100
111
|
options,
|
|
101
112
|
loadOptions,
|
|
@@ -120,11 +131,13 @@ export function EntityCombobox<V extends string | number>({
|
|
|
120
131
|
// belongs to whoever renders both ends of it.
|
|
121
132
|
const listboxId = useId();
|
|
122
133
|
|
|
123
|
-
|
|
134
|
+
// The clear value is "nothing selected" too, whichever one the caller picked.
|
|
135
|
+
const chosen: V | null = value == null || value === clearValue ? null : (value as V);
|
|
136
|
+
const selectedOption = chosen == null ? null : resolve(chosen);
|
|
124
137
|
const q = core.query.trim();
|
|
125
138
|
const showCreate =
|
|
126
139
|
Boolean(onCreate) && q.length > 0 && !results.some((o) => o.label.toLowerCase() === q.toLowerCase());
|
|
127
|
-
const showClear = Boolean(clearable &&
|
|
140
|
+
const showClear = Boolean(clearable && chosen != null && !disabled);
|
|
128
141
|
/** What the closed control is showing — the second half of its accessible name. */
|
|
129
142
|
const triggerText = selectedOption?.label ?? placeholder ?? "";
|
|
130
143
|
|
|
@@ -184,7 +197,7 @@ export function EntityCombobox<V extends string | number>({
|
|
|
184
197
|
}}
|
|
185
198
|
className={cn(
|
|
186
199
|
FIELD_TRIGGER,
|
|
187
|
-
"
|
|
200
|
+
"pe-9",
|
|
188
201
|
label !== undefined && FIELD_FLOATING_PAD,
|
|
189
202
|
disabled && "cursor-not-allowed opacity-50",
|
|
190
203
|
field.isInvalid && FIELD_INVALID,
|
|
@@ -215,9 +228,9 @@ export function EntityCombobox<V extends string | number>({
|
|
|
215
228
|
aria-label={labels.clear}
|
|
216
229
|
onClick={(e) => {
|
|
217
230
|
e.stopPropagation();
|
|
218
|
-
onChange(
|
|
231
|
+
onChange(clearValue);
|
|
219
232
|
}}
|
|
220
|
-
className="absolute
|
|
233
|
+
className="absolute end-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-[var(--text-placeholder)] hover:text-[var(--text-secondary)]"
|
|
221
234
|
>
|
|
222
235
|
<X className="size-4" />
|
|
223
236
|
</span>
|
|
@@ -36,8 +36,14 @@ import { Button, Spinner } from "./ui";
|
|
|
36
36
|
/** Every string the file pickers ({@link FileButton}, `FileDropzone`) render or speak.
|
|
37
37
|
* Messages are functions of the file name so a translation can put it anywhere. */
|
|
38
38
|
export interface FilePickerLabels {
|
|
39
|
-
/** The file's type is not in `accept
|
|
39
|
+
/** The file's type is not in `accept`, when no `accept` is known to name (see
|
|
40
|
+
* `rejectedTypeOnly`). */
|
|
40
41
|
rejectedType: (name: string) => string;
|
|
42
|
+
/** The file's type is not in `accept`, naming what IS accepted: `accept`'s tokens,
|
|
43
|
+
* lower-cased and joined with ", " (`".csv"` => "Only .csv files"). Used whenever
|
|
44
|
+
* `accept` is set — kastlan kept a custom `isValid` + `invalidMessage` per form only
|
|
45
|
+
* to say this, and `accept` already knows it. */
|
|
46
|
+
rejectedTypeOnly: (accept: string, name: string) => string;
|
|
41
47
|
/** The file is larger than `maxSize`; `maxSize` arrives formatted ("5 MB"). */
|
|
42
48
|
rejectedSize: (name: string, maxSize: string) => string;
|
|
43
49
|
/** The file was one too many for `maxFiles`. */
|
|
@@ -46,6 +52,9 @@ export interface FilePickerLabels {
|
|
|
46
52
|
rejectedInvalid: (name: string) => string;
|
|
47
53
|
/** Spoken instead of the per-file message when more than one file was refused. */
|
|
48
54
|
rejectedMany: (count: number) => string;
|
|
55
|
+
/** Spoken (and shown, on a dropzone) when `onPick` refused the WHOLE pick.
|
|
56
|
+
* Receives the number of files in it. */
|
|
57
|
+
rejectedPick: (count: number) => string;
|
|
49
58
|
/** Spoken after a pick the component itself echoes (the dropzone). */
|
|
50
59
|
selected: (count: number, firstName: string) => string;
|
|
51
60
|
/** The dropzone's remove button for one file. */
|
|
@@ -59,11 +68,14 @@ export interface FilePickerLabels {
|
|
|
59
68
|
|
|
60
69
|
export const DEFAULT_FILE_PICKER_LABELS: FilePickerLabels = {
|
|
61
70
|
rejectedType: (name) => `“${name}” is not a supported file type`,
|
|
71
|
+
rejectedTypeOnly: (accept) => `Only ${accept} files`,
|
|
62
72
|
rejectedSize: (name, maxSize) => `“${name}” is larger than ${maxSize}`,
|
|
63
73
|
rejectedCount: (name, maxFiles) =>
|
|
64
74
|
`“${name}” was not added: at most ${maxFiles} ${maxFiles === 1 ? "file" : "files"}`,
|
|
65
75
|
rejectedInvalid: (name) => `“${name}” cannot be used here`,
|
|
66
76
|
rejectedMany: (count) => `${count} files were not added`,
|
|
77
|
+
rejectedPick: (count) =>
|
|
78
|
+
count === 1 ? "The file was not added" : `None of the ${count} files were added`,
|
|
67
79
|
selected: (count, firstName) => (count === 1 ? `“${firstName}” selected` : `${count} files selected`),
|
|
68
80
|
remove: (name) => `Remove “${name}”`,
|
|
69
81
|
clearAll: "Remove all files",
|
|
@@ -136,6 +148,15 @@ function typeFromExtension(name: string): string | undefined {
|
|
|
136
148
|
return dot === -1 ? undefined : EXTENSION_TYPES[name.slice(dot + 1)];
|
|
137
149
|
}
|
|
138
150
|
|
|
151
|
+
/** `" .PDF, image/png ,"` => `".pdf, image/png"` — `accept` as a person reads it. */
|
|
152
|
+
function formatAccept(accept: string): string {
|
|
153
|
+
return accept
|
|
154
|
+
.split(",")
|
|
155
|
+
.map((t) => t.trim().toLowerCase())
|
|
156
|
+
.filter(Boolean)
|
|
157
|
+
.join(", ");
|
|
158
|
+
}
|
|
159
|
+
|
|
139
160
|
/** What the pickers screen a pick with. All optional; nothing set accepts everything. */
|
|
140
161
|
export interface FileScreenOptions {
|
|
141
162
|
accept?: string;
|
|
@@ -160,6 +181,15 @@ export function screenFiles(
|
|
|
160
181
|
): { accepted: File[]; rejected: FileRejection[] } {
|
|
161
182
|
const accepted: File[] = [];
|
|
162
183
|
const rejected: FileRejection[] = [];
|
|
184
|
+
// Name what `accept` takes ("Only .csv files") — unless the host translated
|
|
185
|
+
// `rejectedType` but not yet `rejectedTypeOnly`: its own sentence beats an English one.
|
|
186
|
+
const acceptText = opts.accept ? formatAccept(opts.accept) : "";
|
|
187
|
+
const useOnly =
|
|
188
|
+
acceptText !== "" &&
|
|
189
|
+
(labels.rejectedTypeOnly !== DEFAULT_FILE_PICKER_LABELS.rejectedTypeOnly ||
|
|
190
|
+
labels.rejectedType === DEFAULT_FILE_PICKER_LABELS.rejectedType);
|
|
191
|
+
const typeMessage = (name: string) =>
|
|
192
|
+
useOnly ? labels.rejectedTypeOnly(acceptText, name) : labels.rejectedType(name);
|
|
163
193
|
for (const file of files) {
|
|
164
194
|
let reason: FileRejectionReason | null = null;
|
|
165
195
|
if (!matchesAccept(file, opts.accept)) reason = "type";
|
|
@@ -174,7 +204,7 @@ export function screenFiles(
|
|
|
174
204
|
}
|
|
175
205
|
const message =
|
|
176
206
|
reason === "type"
|
|
177
|
-
?
|
|
207
|
+
? typeMessage(file.name)
|
|
178
208
|
: reason === "size"
|
|
179
209
|
? labels.rejectedSize(file.name, formatSize(opts.maxSize ?? 0))
|
|
180
210
|
: reason === "count"
|
|
@@ -185,6 +215,34 @@ export function screenFiles(
|
|
|
185
215
|
return { accepted, rejected };
|
|
186
216
|
}
|
|
187
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Judges a pick as a whole — keksdose's "refuse the pick if any file is bad", which
|
|
220
|
+
* with `onFiles` + `onReject` alone had to be rebuilt from two calls in a microtask.
|
|
221
|
+
*
|
|
222
|
+
* Called ONCE per pick, after screening and before `onFiles` / `onReject`, with both
|
|
223
|
+
* halves (either may be empty). Return `false` to refuse the whole pick: the accepted
|
|
224
|
+
* files are not delivered, `onReject` still receives what screening refused, and one
|
|
225
|
+
* sentence (`labels.rejectedPick`) is spoken for the pick instead of a per-file one.
|
|
226
|
+
* Return nothing to let the pick through as usual.
|
|
227
|
+
*
|
|
228
|
+
* ```tsx
|
|
229
|
+
* onPick={(ok, bad) => bad.length === 0 || false} // all or nothing
|
|
230
|
+
* ```
|
|
231
|
+
*/
|
|
232
|
+
export type FilePickHandler = (accepted: File[], rejected: FileRejection[]) => boolean | void;
|
|
233
|
+
|
|
234
|
+
/** @internal The shared tail of both pickers: run `onPick`, and say whether the pick
|
|
235
|
+
* stands. A pick with nothing accepted has nothing to refuse, so `false` there is
|
|
236
|
+
* treated as no verdict and the usual per-file messages are kept. */
|
|
237
|
+
export function judgePick(
|
|
238
|
+
onPick: FilePickHandler | undefined,
|
|
239
|
+
accepted: File[],
|
|
240
|
+
rejected: FileRejection[],
|
|
241
|
+
): boolean {
|
|
242
|
+
if (!onPick) return true;
|
|
243
|
+
return onPick(accepted, rejected) !== false || accepted.length === 0;
|
|
244
|
+
}
|
|
245
|
+
|
|
188
246
|
/** One sentence for a whole batch of refusals: the file's own message for one, a
|
|
189
247
|
* count for several — reading out five sentences in a row helps nobody. */
|
|
190
248
|
export function summariseRejections(rejected: readonly FileRejection[], labels: FilePickerLabels): string {
|
|
@@ -205,8 +263,11 @@ export interface UseFilePickerOptions extends FileScreenOptions {
|
|
|
205
263
|
* keksdose's camera input has no `multiple`. Pass one or the other.
|
|
206
264
|
*/
|
|
207
265
|
capture?: boolean | "user" | "environment";
|
|
208
|
-
/** The files that passed, in pick order. Never called with an empty array.
|
|
209
|
-
|
|
266
|
+
/** The files that passed, in pick order. Never called with an empty array.
|
|
267
|
+
* Optional since 0.7 for a caller that takes the pick through `onPick` alone. */
|
|
268
|
+
onFiles?: (files: File[]) => void;
|
|
269
|
+
/** See {@link FilePickHandler}: the whole pick at once, with the power to refuse it. */
|
|
270
|
+
onPick?: FilePickHandler;
|
|
210
271
|
/** The files that did not, with a translated message each. The refusals are also
|
|
211
272
|
* spoken through a live region, so this is for SHOWING them, not for a11y. */
|
|
212
273
|
onReject?: (rejections: FileRejection[]) => void;
|
|
@@ -246,6 +307,7 @@ export function useFilePicker({
|
|
|
246
307
|
isValid,
|
|
247
308
|
invalidMessage,
|
|
248
309
|
onFiles,
|
|
310
|
+
onPick,
|
|
249
311
|
onReject,
|
|
250
312
|
disabled,
|
|
251
313
|
labels: labelsProp,
|
|
@@ -269,7 +331,12 @@ export function useFilePicker({
|
|
|
269
331
|
labels,
|
|
270
332
|
fileText.size,
|
|
271
333
|
);
|
|
272
|
-
if (accepted
|
|
334
|
+
if (!judgePick(onPick, accepted, rejected)) {
|
|
335
|
+
if (rejected.length > 0) onReject?.(rejected);
|
|
336
|
+
announce(labels.rejectedPick(files.length));
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (accepted.length > 0) onFiles?.(accepted);
|
|
273
340
|
if (rejected.length > 0) {
|
|
274
341
|
onReject?.(rejected);
|
|
275
342
|
announce(summariseRejections(rejected, labels));
|
|
@@ -355,6 +422,7 @@ export const FileButton = forwardRef<HTMLButtonElement, FileButtonProps>(functio
|
|
|
355
422
|
isValid,
|
|
356
423
|
invalidMessage,
|
|
357
424
|
onFiles,
|
|
425
|
+
onPick,
|
|
358
426
|
onReject,
|
|
359
427
|
labels,
|
|
360
428
|
pending,
|
|
@@ -381,6 +449,7 @@ export const FileButton = forwardRef<HTMLButtonElement, FileButtonProps>(functio
|
|
|
381
449
|
isValid,
|
|
382
450
|
invalidMessage,
|
|
383
451
|
onFiles,
|
|
452
|
+
onPick,
|
|
384
453
|
onReject,
|
|
385
454
|
labels,
|
|
386
455
|
disabled: inert,
|