@attrus-ds/core 1.0.20 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/css/components/buttons.css +56 -3
- package/dist/css/components/callout.css +55 -5
- package/dist/css/components/combobox.css +18 -0
- package/dist/css/components/filters.css +123 -0
- package/dist/css/components/inputs.css +52 -0
- package/dist/css/components/sidebar.css +8 -0
- package/dist/css/components/status-pills.css +22 -3
- package/dist/css/components/table.css +121 -7
- package/dist/css/components/toggle-chip.css +9 -0
- package/dist/css/index.css +1 -0
- package/dist/css/tokens.css +7 -0
- package/dist/react/Combobox/Combobox.d.ts +3 -0
- package/dist/react/Combobox/Combobox.tsx +18 -4
- package/dist/react/DTFilters/DTFilters.d.ts +34 -0
- package/dist/react/DTFilters/DTFilters.tsx +237 -0
- package/dist/react/DataTable/DataTable.d.ts +15 -3
- package/dist/react/DataTable/DataTable.tsx +73 -17
- package/dist/react/DateRangePicker/DateRangePicker.d.ts +24 -0
- package/dist/react/DateRangePicker/DateRangePicker.tsx +140 -0
- package/dist/react/Input/Input.tsx +1 -1
- package/dist/react/Pill/Pill.tsx +3 -1
- package/dist/react/index.ts +2 -0
- package/package.json +1 -1
|
@@ -20,6 +20,9 @@ export interface ComboOption {
|
|
|
20
20
|
disabled?: boolean;
|
|
21
21
|
/** Text used by the search filter when label isn't a plain string. */
|
|
22
22
|
searchText?: string;
|
|
23
|
+
/** Leading glyph in the list — and the WHOLE trigger when the combobox is
|
|
24
|
+
`iconOnly` (the icon is the value in use, e.g. a sort order). */
|
|
25
|
+
icon?: React.ReactNode;
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
export type ComboboxStatus = 'default' | 'error';
|
|
@@ -59,6 +62,12 @@ export interface ComboboxProps {
|
|
|
59
62
|
'end' for a compact control near the right edge, where growing right would
|
|
60
63
|
run off the viewport. */
|
|
61
64
|
align?: 'start' | 'end';
|
|
65
|
+
/** sm = 32px trigger (--control-h-sm), for a table toolbar beside sm buttons. */
|
|
66
|
+
size?: 'sm' | 'md';
|
|
67
|
+
/** Trigger shows only the selected option's icon + chevron; the label stays in
|
|
68
|
+
the DOM as sr-only, so the trigger is still announced by value. The list
|
|
69
|
+
keeps icon + text. Give every option an `icon`. */
|
|
70
|
+
iconOnly?: boolean;
|
|
62
71
|
className?: string;
|
|
63
72
|
style?: React.CSSProperties;
|
|
64
73
|
'aria-label'?: string;
|
|
@@ -89,6 +98,8 @@ export const Combobox: React.FC<ComboboxProps> = ({
|
|
|
89
98
|
searchPlaceholder = 'Search…',
|
|
90
99
|
icon,
|
|
91
100
|
status = 'default',
|
|
101
|
+
size = 'md',
|
|
102
|
+
iconOnly = false,
|
|
92
103
|
filters,
|
|
93
104
|
meta,
|
|
94
105
|
footMeta,
|
|
@@ -212,6 +223,7 @@ export const Combobox: React.FC<ComboboxProps> = ({
|
|
|
212
223
|
setQ('');
|
|
213
224
|
}}
|
|
214
225
|
>
|
|
226
|
+
{o.icon != null ? <span className="combobox-option-icon" aria-hidden="true">{o.icon}</span> : null}
|
|
215
227
|
<span className="combobox-option-content">
|
|
216
228
|
<span className="combobox-option-label">{o.label}</span>
|
|
217
229
|
{o.meta != null ? <span className="combobox-option-meta">{o.meta}</span> : null}
|
|
@@ -258,7 +270,7 @@ export const Combobox: React.FC<ComboboxProps> = ({
|
|
|
258
270
|
const tree = (
|
|
259
271
|
<div
|
|
260
272
|
ref={wrapRef}
|
|
261
|
-
className={['combobox', open ? 'is-open' : '', status === 'error' ? 'is-invalid' : '', className || ''].filter(Boolean).join(' ')}
|
|
273
|
+
className={['combobox', open ? 'is-open' : '', status === 'error' ? 'is-invalid' : '', size === 'sm' ? 'combobox-sm' : '', iconOnly ? 'is-icon-only' : '', className || ''].filter(Boolean).join(' ')}
|
|
262
274
|
style={style}
|
|
263
275
|
onKeyDown={onWrapKeyDown}
|
|
264
276
|
>
|
|
@@ -271,8 +283,10 @@ export const Combobox: React.FC<ComboboxProps> = ({
|
|
|
271
283
|
onClick={() => { setOpen((o) => !o); setActive(value); }}
|
|
272
284
|
{...rest}
|
|
273
285
|
>
|
|
274
|
-
{
|
|
275
|
-
|
|
286
|
+
{iconOnly
|
|
287
|
+
? <span className="combobox-trigger-icon">{(selected && selected.icon) != null ? selected!.icon : icon}</span>
|
|
288
|
+
: icon != null ? <span className="combobox-trigger-icon">{icon}</span> : null}
|
|
289
|
+
<span className={iconOnly ? 'combobox-value sr-only' : 'combobox-value'}>
|
|
276
290
|
{selected ? selected.label : <span className="combobox-placeholder">{placeholder}</span>}
|
|
277
291
|
</span>
|
|
278
292
|
<Chevron />
|
|
@@ -292,7 +306,7 @@ export const Combobox: React.FC<ComboboxProps> = ({
|
|
|
292
306
|
{open && pos
|
|
293
307
|
? createPortal(
|
|
294
308
|
<div
|
|
295
|
-
className={['combobox', 'is-open', status === 'error' ? 'is-invalid' : '', className || ''].filter(Boolean).join(' ')}
|
|
309
|
+
className={['combobox', 'is-open', status === 'error' ? 'is-invalid' : '', size === 'sm' ? 'combobox-sm' : '', className || ''].filter(Boolean).join(' ')}
|
|
296
310
|
style={{
|
|
297
311
|
/* MIN-width, not width: matching the trigger is right for a form
|
|
298
312
|
field, but as a fixed width it traps the list — a 50px chip
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
export interface DTFilterScope {
|
|
4
|
+
value: string;
|
|
5
|
+
label: string;
|
|
6
|
+
placeholder?: string;
|
|
7
|
+
inputMode?: React.HTMLAttributes<HTMLInputElement>['inputMode'];
|
|
8
|
+
}
|
|
9
|
+
export interface DTFilterValue { scope: string | null; query: string; from: string; to: string; }
|
|
10
|
+
export interface DTFiltersLabels {
|
|
11
|
+
searchBy: string; period: string; apply: string; clear: string; search: string;
|
|
12
|
+
pendingHint: string; remove: string; undo: string;
|
|
13
|
+
}
|
|
14
|
+
export interface DTFiltersProps {
|
|
15
|
+
/** "apply" (default): a draft committed by Apply / Enter — large or server lists.
|
|
16
|
+
"live": applies as you go, text debounced — small local lists. */
|
|
17
|
+
mode?: 'apply' | 'live';
|
|
18
|
+
scopes: DTFilterScope[];
|
|
19
|
+
/** The applied filters. */
|
|
20
|
+
value: DTFilterValue;
|
|
21
|
+
onChange: (next: DTFilterValue) => void;
|
|
22
|
+
/** live mode only: show the applied filters as removable pills. */
|
|
23
|
+
showPills?: boolean;
|
|
24
|
+
debounce?: number;
|
|
25
|
+
labels?: Partial<DTFiltersLabels>;
|
|
26
|
+
locale?: string;
|
|
27
|
+
className?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export declare const DTFilters: React.FC<DTFiltersProps>;
|
|
31
|
+
export declare const EMPTY_FILTERS: DTFilterValue;
|
|
32
|
+
/** Compare only the digits of both sides: "40095" finds "40,095.00". */
|
|
33
|
+
export declare function matchesDigits(haystack: string | number, needle: string): boolean;
|
|
34
|
+
export default DTFilters;
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { DateRangePicker } from '../DateRangePicker/DateRangePicker';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ATTRUS DTFilters — the search + period bar of a data table, for DTToolbar's
|
|
6
|
+
* filters zone. One component, two commit models:
|
|
7
|
+
*
|
|
8
|
+
* mode="apply" (default) — the controls edit a DRAFT; nothing reaches the
|
|
9
|
+
* table until "Apply filters" or Enter. A second row under a hairline opens
|
|
10
|
+
* while anything is pending or applied: Apply (with a hint balloon while
|
|
11
|
+
* dirty), Clear filters (once something is applied), a rule, then the
|
|
12
|
+
* applied filters as pills. A ✕ on a pill clears the control and leaves the
|
|
13
|
+
* pill dashed and struck through with an undo, until the next Apply.
|
|
14
|
+
* Use for large or server-side lists, where each change costs a request.
|
|
15
|
+
*
|
|
16
|
+
* mode="live" — no second row; the period and the scope apply at once and the
|
|
17
|
+
* text after a short debounce. Pills are optional and their ✕ removes now.
|
|
18
|
+
* Use for small local lists, where filtering is free.
|
|
19
|
+
*
|
|
20
|
+
* Status tabs are NOT part of this bar: they sit above the table and always
|
|
21
|
+
* apply immediately, in both modes.
|
|
22
|
+
*/
|
|
23
|
+
export interface DTFilterScope {
|
|
24
|
+
value: string;
|
|
25
|
+
label: string;
|
|
26
|
+
placeholder?: string;
|
|
27
|
+
/** "decimal" for an amount scope — see matchesDigits. */
|
|
28
|
+
inputMode?: React.HTMLAttributes<HTMLInputElement>['inputMode'];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DTFilterValue { scope: string | null; query: string; from: string; to: string; }
|
|
32
|
+
|
|
33
|
+
export interface DTFiltersLabels {
|
|
34
|
+
searchBy: string; period: string; apply: string; clear: string; search: string;
|
|
35
|
+
pendingHint: string; remove: string; undo: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface DTFiltersProps {
|
|
39
|
+
mode?: 'apply' | 'live';
|
|
40
|
+
scopes: DTFilterScope[];
|
|
41
|
+
/** The APPLIED filters. */
|
|
42
|
+
value: DTFilterValue;
|
|
43
|
+
/** apply: on Apply / Enter / Clear. live: on every change (text debounced). */
|
|
44
|
+
onChange: (next: DTFilterValue) => void;
|
|
45
|
+
/** live mode only — show the applied filters as removable pills. */
|
|
46
|
+
showPills?: boolean;
|
|
47
|
+
/** Debounce for the text in live mode, ms. */
|
|
48
|
+
debounce?: number;
|
|
49
|
+
labels?: Partial<DTFiltersLabels>;
|
|
50
|
+
locale?: string;
|
|
51
|
+
className?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const EMPTY_FILTERS: DTFilterValue = { scope: null, query: '', from: '', to: '' };
|
|
55
|
+
|
|
56
|
+
const LABELS: DTFiltersLabels = {
|
|
57
|
+
searchBy: 'Search by', period: 'Period', apply: 'Apply filters', clear: 'Clear filters', search: 'Search',
|
|
58
|
+
pendingHint: 'You have changes that are not applied yet.', remove: 'Remove filter', undo: 'Undo removal',
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Value search that ignores formatting: "40095", "40,095.00" and "40.095,00"
|
|
62
|
+
all find 40,095.00 — only the digits of both sides are compared. */
|
|
63
|
+
export function matchesDigits(haystack: string | number, needle: string): boolean {
|
|
64
|
+
const n = String(needle).replace(/\D/g, '');
|
|
65
|
+
if (!n) return true;
|
|
66
|
+
return String(haystack).replace(/\D/g, '').indexOf(n) > -1;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function fmtDate(s: string, locale: string) {
|
|
70
|
+
if (!s) return '…';
|
|
71
|
+
const [y, m, d] = s.split('-').map(Number);
|
|
72
|
+
return new Date(y, m - 1, d).toLocaleDateString(locale, { month: 'short', day: 'numeric' });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const Ico = {
|
|
76
|
+
x: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>,
|
|
77
|
+
undo: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" /><path d="M3 3v5h5" /></svg>,
|
|
78
|
+
filter: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z" /></svg>,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Keeps a removed item mounted for its exit transition (180ms). */
|
|
82
|
+
function usePresence<T extends { key: string }>(items: T[], ms = 180) {
|
|
83
|
+
const [shown, setShown] = React.useState<(T & { phase: 'in' | 'entering' | 'leaving' })[]>(
|
|
84
|
+
() => items.map((i) => Object.assign({}, i, { phase: 'in' as const })));
|
|
85
|
+
React.useEffect(() => {
|
|
86
|
+
const keys = new Set(items.map((i) => i.key));
|
|
87
|
+
setShown((prev) => {
|
|
88
|
+
const prevKeys = new Set(prev.map((p) => p.key));
|
|
89
|
+
const next = prev.map((p) => keys.has(p.key)
|
|
90
|
+
? Object.assign({}, items.find((i) => i.key === p.key)!, { phase: p.phase === 'leaving' ? 'entering' as const : p.phase })
|
|
91
|
+
: Object.assign({}, p, { phase: 'leaving' as const }));
|
|
92
|
+
items.forEach((i) => { if (!prevKeys.has(i.key)) next.push(Object.assign({}, i, { phase: 'entering' as const })); });
|
|
93
|
+
return next;
|
|
94
|
+
});
|
|
95
|
+
const enter = setTimeout(() => setShown((p) => p.map((x) => x.phase === 'entering' ? Object.assign({}, x, { phase: 'in' as const }) : x)), 20);
|
|
96
|
+
const leave = setTimeout(() => setShown((p) => p.filter((x) => x.phase !== 'leaving')), ms);
|
|
97
|
+
return () => { clearTimeout(enter); clearTimeout(leave); };
|
|
98
|
+
}, [items.map((i) => i.key + (i as any).pending).join('|')]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
99
|
+
return shown;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const DTFilters: React.FC<DTFiltersProps> = ({
|
|
103
|
+
mode = 'apply', scopes, value, onChange, showPills = false, debounce = 250, labels, locale = 'en-US', className,
|
|
104
|
+
}) => {
|
|
105
|
+
const L = Object.assign({}, LABELS, labels);
|
|
106
|
+
const live = mode === 'live';
|
|
107
|
+
const [draft, setDraft] = React.useState<DTFilterValue>(value);
|
|
108
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
109
|
+
const rootRef = React.useRef<HTMLDivElement>(null);
|
|
110
|
+
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
111
|
+
|
|
112
|
+
// An outside change to the applied value (e.g. a Clear from elsewhere) resets the draft.
|
|
113
|
+
const appliedKey = JSON.stringify(value);
|
|
114
|
+
React.useEffect(() => { setDraft(value); }, [appliedKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
115
|
+
|
|
116
|
+
const edit = (patch: Partial<DTFilterValue>, textOnly = false) => {
|
|
117
|
+
const next = Object.assign({}, draft, patch);
|
|
118
|
+
setDraft(next);
|
|
119
|
+
if (!live) return;
|
|
120
|
+
if (timer.current) clearTimeout(timer.current);
|
|
121
|
+
if (textOnly) timer.current = setTimeout(() => onChange(next), debounce);
|
|
122
|
+
else onChange(next);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const dirty = !live && JSON.stringify(draft) !== appliedKey;
|
|
126
|
+
const hasApplied = !!(value.query || value.from || value.to);
|
|
127
|
+
const apply = () => { if (dirty) onChange(draft); };
|
|
128
|
+
|
|
129
|
+
const pickScope = (s: string) => {
|
|
130
|
+
if (draft.scope === s) { edit({ scope: null, query: '' }); return; } // picked again: close + clear
|
|
131
|
+
edit({ scope: s });
|
|
132
|
+
setTimeout(() => inputRef.current?.focus(), 0);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// Enter applies — from the search, the date trigger, or with nothing focused.
|
|
136
|
+
// Buttons and pills keep their own Enter.
|
|
137
|
+
React.useEffect(() => {
|
|
138
|
+
if (live) return undefined;
|
|
139
|
+
const onKey = (e: KeyboardEvent) => {
|
|
140
|
+
if (e.key !== 'Enter' || !dirty) return;
|
|
141
|
+
const t = e.target as HTMLElement;
|
|
142
|
+
const inBar = rootRef.current?.contains(t);
|
|
143
|
+
const nothing = t === document.body;
|
|
144
|
+
const isField = t.matches('.dt-filters-field input, .date-trigger');
|
|
145
|
+
if (nothing || (inBar && isField)) { e.preventDefault(); e.stopPropagation(); apply(); }
|
|
146
|
+
};
|
|
147
|
+
document.addEventListener('keydown', onKey, true);
|
|
148
|
+
return () => document.removeEventListener('keydown', onKey, true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const scope = scopes.find((s) => s.value === draft.scope) || null;
|
|
152
|
+
const appliedScope = scopes.find((s) => s.value === value.scope);
|
|
153
|
+
|
|
154
|
+
// Pills come from what is APPLIED (live: from the draft, which is the applied state).
|
|
155
|
+
const src = live ? draft : value;
|
|
156
|
+
const pillScope = scopes.find((s) => s.value === src.scope);
|
|
157
|
+
const pills: { key: string; label: string; pending: boolean }[] = [];
|
|
158
|
+
if (src.query) pills.push({ key: 'query', label: (pillScope ? pillScope.label : '') + ': "' + src.query + '"', pending: !live && (draft.query !== value.query || draft.scope !== value.scope) });
|
|
159
|
+
if (src.from || src.to) pills.push({ key: 'period', label: L.period + ': ' + fmtDate(src.from, locale) + ' – ' + fmtDate(src.to, locale), pending: !live && (draft.from !== value.from || draft.to !== value.to) });
|
|
160
|
+
const shownPills = usePresence(pills);
|
|
161
|
+
|
|
162
|
+
const clearPill = (k: string) => k === 'query' ? edit({ query: '', scope: null }) : edit({ from: '', to: '' });
|
|
163
|
+
const restorePill = (k: string) => k === 'query'
|
|
164
|
+
? setDraft(Object.assign({}, draft, { query: value.query, scope: value.scope }))
|
|
165
|
+
: setDraft(Object.assign({}, draft, { from: value.from, to: value.to }));
|
|
166
|
+
|
|
167
|
+
const pillEls = shownPills.map((p) => (
|
|
168
|
+
<span key={p.key} className={['dt-filter-pill', 'dt-filters-item', p.pending ? 'is-pending' : '', p.phase !== 'in' ? 'is-' + p.phase : ''].filter(Boolean).join(' ')}>
|
|
169
|
+
<span className="dt-filter-pill-label">{p.label}</span>
|
|
170
|
+
{p.pending
|
|
171
|
+
? <button type="button" className="dt-filter-pill-action" aria-label={L.undo + ': ' + p.label} onClick={() => restorePill(p.key)}>{Ico.undo}</button>
|
|
172
|
+
: <button type="button" className="dt-filter-pill-action" aria-label={L.remove + ': ' + p.label} onClick={() => clearPill(p.key)}>{Ico.x}</button>}
|
|
173
|
+
</span>
|
|
174
|
+
));
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<div ref={rootRef} className={['dt-filters', live ? 'is-live' : 'is-apply', className || ''].filter(Boolean).join(' ')}>
|
|
178
|
+
<div className="dt-filters-row">
|
|
179
|
+
<div className="dt-filters-scope">
|
|
180
|
+
<span className="dt-filters-scope-label" id={'dtf-by-' + (scopes[0] && scopes[0].value)}>{L.searchBy}</span>
|
|
181
|
+
<div className="btn-group" role="group" aria-labelledby={'dtf-by-' + (scopes[0] && scopes[0].value)}>
|
|
182
|
+
{scopes.map((s) => (
|
|
183
|
+
<button key={s.value} type="button" className="btn btn-secondary btn-sm" aria-pressed={draft.scope === s.value} onClick={() => pickScope(s.value)}>{s.label}</button>
|
|
184
|
+
))}
|
|
185
|
+
</div>
|
|
186
|
+
<div className={['dt-filters-field', scope ? 'is-open' : ''].filter(Boolean).join(' ')} aria-hidden={!scope}>
|
|
187
|
+
{live ? (
|
|
188
|
+
/* live: the text already applies after a debounce; the inset Search
|
|
189
|
+
button (tertiary — a search is not the screen's CTA) commits it now,
|
|
190
|
+
for a user who would rather not wait. */
|
|
191
|
+
<div className="group group-sm">
|
|
192
|
+
<input ref={inputRef} tabIndex={scope ? 0 : -1}
|
|
193
|
+
inputMode={scope ? scope.inputMode : undefined}
|
|
194
|
+
placeholder={scope ? scope.placeholder || scope.label : ''} aria-label={scope ? scope.placeholder || scope.label : undefined}
|
|
195
|
+
value={draft.query} onChange={(e) => edit({ query: e.target.value }, true)} />
|
|
196
|
+
<button type="button" className="fix-btn r is-tertiary" tabIndex={scope ? 0 : -1}
|
|
197
|
+
disabled={!draft.query.trim()}
|
|
198
|
+
onClick={() => edit({ query: draft.query })}>{L.search}</button>
|
|
199
|
+
</div>
|
|
200
|
+
) : (
|
|
201
|
+
<input ref={inputRef} className="input input-sm" tabIndex={scope ? 0 : -1}
|
|
202
|
+
inputMode={scope ? scope.inputMode : undefined}
|
|
203
|
+
placeholder={scope ? scope.placeholder || scope.label : ''} aria-label={scope ? scope.placeholder || scope.label : undefined}
|
|
204
|
+
value={draft.query} onChange={(e) => edit({ query: e.target.value }, true)} />
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
<DateRangePicker size="sm" portal from={draft.from} to={draft.to} placeholder={L.period} locale={locale}
|
|
209
|
+
aria-label={L.period} onChange={(f, t) => edit({ from: f, to: t })} />
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
{!live ? (
|
|
213
|
+
<div className={['dt-filters-apply', dirty || hasApplied ? 'is-open' : ''].filter(Boolean).join(' ')} aria-hidden={!(dirty || hasApplied)}>
|
|
214
|
+
<div className="dt-filters-apply-inner">
|
|
215
|
+
<span className="dt-filters-applywrap">
|
|
216
|
+
<button type="button" className="btn btn-primary btn-sm" disabled={!dirty} onClick={apply}>{Ico.filter}<span>{L.apply}</span></button>
|
|
217
|
+
<span className={['dt-filters-hint', dirty ? 'is-visible' : ''].filter(Boolean).join(' ')} role="status">{dirty ? L.pendingHint : ''}</span>
|
|
218
|
+
</span>
|
|
219
|
+
{hasApplied ? (
|
|
220
|
+
<button type="button" className="btn btn-ghost btn-sm dt-filters-item" onClick={() => { setDraft(EMPTY_FILTERS); onChange(EMPTY_FILTERS); }}>{L.clear}</button>
|
|
221
|
+
) : null}
|
|
222
|
+
{pillEls.length ? <span className="dt-filters-vsep" aria-hidden="true" /> : null}
|
|
223
|
+
{pillEls}
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
) : live && showPills ? (
|
|
227
|
+
/* live: the same second row under the same hairline, holding only the
|
|
228
|
+
pills — there is nothing to apply, so no Apply / Clear / rule. */
|
|
229
|
+
<div className={['dt-filters-apply', 'is-live', pills.length ? 'is-open' : ''].filter(Boolean).join(' ')} aria-hidden={!pills.length}>
|
|
230
|
+
<div className="dt-filters-apply-inner">{pillEls}</div>
|
|
231
|
+
</div>
|
|
232
|
+
) : null}
|
|
233
|
+
</div>
|
|
234
|
+
);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
export default DTFilters;
|
|
@@ -126,9 +126,11 @@ export declare const DTSubrowLead: React.FC<{
|
|
|
126
126
|
/** Disclosure control for a collapsible subrow group — goes in the parent's
|
|
127
127
|
lead cell. `controls` must match the subrow's id. */
|
|
128
128
|
export declare const DTSubrowToggle: React.FC<{
|
|
129
|
-
expanded
|
|
130
|
-
onToggle
|
|
131
|
-
controls
|
|
129
|
+
expanded?: boolean;
|
|
130
|
+
onToggle?: () => void;
|
|
131
|
+
controls?: string;
|
|
132
|
+
/** Holds the chevron's 20px on a row without children, so names align. */
|
|
133
|
+
spacer?: boolean;
|
|
132
134
|
count?: number;
|
|
133
135
|
label?: string;
|
|
134
136
|
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'aria-expanded'>>;
|
|
@@ -157,6 +159,10 @@ export interface DTParentRowProps extends React.HTMLAttributes<HTMLTableRowEleme
|
|
|
157
159
|
open?: boolean;
|
|
158
160
|
onToggle?: () => void;
|
|
159
161
|
controls?: string;
|
|
162
|
+
/** "toggle" (default): the row is the disclosure. "open": the row opens
|
|
163
|
+
something and only the chevron collapses — use DTRowButton for the name. */
|
|
164
|
+
rowAction?: 'toggle' | 'open';
|
|
165
|
+
onOpen?: () => void;
|
|
160
166
|
}
|
|
161
167
|
export declare const DTId: React.FC<React.HTMLAttributes<HTMLSpanElement>>;
|
|
162
168
|
export declare const DTCheckCell: React.FC<React.TdHTMLAttributes<HTMLTableCellElement>>;
|
|
@@ -167,6 +173,12 @@ export declare const DTScrollY: React.FC<React.HTMLAttributes<HTMLDivElement>>;
|
|
|
167
173
|
|
|
168
174
|
export declare const DTParentRow: React.FC<DTParentRowProps>;
|
|
169
175
|
|
|
176
|
+
/** Keyboard handle for a rowAction="open" row: the name as a <button>. */
|
|
177
|
+
/** "Showing n of total" strip above DTToolbar; renders only while filtered. */
|
|
178
|
+
export declare const DTFilteredBand: React.FC<{ filtered: boolean; children?: React.ReactNode; className?: string }>;
|
|
179
|
+
|
|
180
|
+
export declare const DTRowButton: React.FC<React.ButtonHTMLAttributes<HTMLButtonElement>>;
|
|
181
|
+
|
|
170
182
|
export declare const DTPagination: React.FC<DTPaginationProps>;
|
|
171
183
|
|
|
172
184
|
export default DataTable;
|
|
@@ -431,22 +431,27 @@ export const DTSubrowLead: React.FC<{
|
|
|
431
431
|
/** Disclosure control for a collapsible subrow group — put it in the parent's
|
|
432
432
|
lead cell. Wire `controls` to the subrow's id so `aria-controls` resolves. */
|
|
433
433
|
export const DTSubrowToggle: React.FC<{
|
|
434
|
-
expanded
|
|
435
|
-
onToggle
|
|
436
|
-
controls
|
|
434
|
+
expanded?: boolean;
|
|
435
|
+
onToggle?: () => void;
|
|
436
|
+
controls?: string;
|
|
437
|
+
/** Holds the chevron's 20px on a row WITHOUT children, so names in a column
|
|
438
|
+
line up whether or not the row expands. Renders nothing interactive. */
|
|
439
|
+
spacer?: boolean;
|
|
437
440
|
/** Announced as "Show 2 items" / "Hide 2 items". */
|
|
438
441
|
count?: number;
|
|
439
442
|
label?: string;
|
|
440
443
|
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'aria-expanded'>> = ({
|
|
441
|
-
expanded, onToggle, controls, count, label, className, ...rest
|
|
442
|
-
}) => (
|
|
444
|
+
expanded = false, onToggle, controls, count, label, spacer, className, ...rest
|
|
445
|
+
}) => spacer ? (
|
|
446
|
+
<span className={['dt-subrow-toggle', 'is-spacer', className || ''].filter(Boolean).join(' ')} aria-hidden="true" />
|
|
447
|
+
) : (
|
|
443
448
|
<button
|
|
444
449
|
type="button"
|
|
445
450
|
className={['dt-subrow-toggle', className || ''].filter(Boolean).join(' ')}
|
|
446
451
|
aria-expanded={expanded}
|
|
447
452
|
aria-controls={controls}
|
|
448
453
|
aria-label={(expanded ? 'Hide' : 'Show') + ' ' + (count != null ? count + ' ' : '') + (label || 'linked items')}
|
|
449
|
-
onClick={(e) => { e.stopPropagation(); onToggle(); }}
|
|
454
|
+
onClick={(e) => { e.stopPropagation(); if (onToggle) onToggle(); }}
|
|
450
455
|
{...rest}
|
|
451
456
|
>
|
|
452
457
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
@@ -682,23 +687,74 @@ export interface DTParentRowProps extends React.HTMLAttributes<HTMLTableRowEleme
|
|
|
682
687
|
/** The group's kind: subrows share the parent's columns, a detail panel does not. */
|
|
683
688
|
kind?: 'subrows' | 'detail';
|
|
684
689
|
open?: boolean;
|
|
685
|
-
/** Whole-row disclosure — adds role/tabindex and the
|
|
690
|
+
/** Whole-row disclosure (rowAction="toggle") — adds role/tabindex and the
|
|
691
|
+
pointer affordance. With rowAction="open" only the chevron toggles. */
|
|
686
692
|
onToggle?: () => void;
|
|
687
693
|
controls?: string;
|
|
694
|
+
/** What a click on the ROW does.
|
|
695
|
+
"toggle" (default) — the row is the disclosure; the chevron is decorative.
|
|
696
|
+
"open" — the row opens something (a detail drawer); only the chevron
|
|
697
|
+
(DTSubrowToggle) collapses the group. The row is not a button itself —
|
|
698
|
+
put the name in a DTRowButton so the action has a keyboard handle. */
|
|
699
|
+
rowAction?: 'toggle' | 'open';
|
|
700
|
+
/** Called when rowAction="open" and the row is clicked. */
|
|
701
|
+
onOpen?: () => void;
|
|
688
702
|
}
|
|
689
|
-
export const DTParentRow: React.FC<DTParentRowProps> = ({ children, kind = 'subrows', open, onToggle, controls, className, ...rest }) =>
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
703
|
+
export const DTParentRow: React.FC<DTParentRowProps> = ({ children, kind = 'subrows', open, onToggle, controls, rowAction = 'toggle', onOpen, className, ...rest }) => {
|
|
704
|
+
const opens = rowAction === 'open';
|
|
705
|
+
const toggles = !opens && !!onToggle;
|
|
706
|
+
return (
|
|
707
|
+
<tr
|
|
708
|
+
className={[
|
|
709
|
+
kind === 'detail' ? 'has-detail' : 'has-subrows',
|
|
710
|
+
onToggle ? 'is-collapsible' : '',
|
|
711
|
+
toggles ? 'is-clickable' : '',
|
|
712
|
+
opens ? 'is-actionable' : '',
|
|
713
|
+
open ? 'is-open' : '',
|
|
714
|
+
className || '',
|
|
715
|
+
].filter(Boolean).join(' ')}
|
|
716
|
+
role={toggles ? 'button' : undefined}
|
|
717
|
+
tabIndex={toggles ? 0 : undefined}
|
|
718
|
+
aria-expanded={toggles ? !!open : undefined}
|
|
719
|
+
aria-controls={toggles ? controls : undefined}
|
|
720
|
+
onClick={toggles ? onToggle : opens ? onOpen : undefined}
|
|
721
|
+
onKeyDown={toggles ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle!(); } } : undefined}
|
|
722
|
+
{...rest}
|
|
723
|
+
>
|
|
724
|
+
{children}
|
|
725
|
+
</tr>
|
|
726
|
+
);
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
/** "Showing n of total" strip, first child of .dt-wrap above DTToolbar. Renders
|
|
730
|
+
only while `filtered` (any applied filter, incl. a status tab other than All);
|
|
731
|
+
takes no space otherwise. The live region announces the new count. */
|
|
732
|
+
export const DTFilteredBand: React.FC<{
|
|
733
|
+
filtered: boolean;
|
|
734
|
+
/** e.g. "Showing 3 of 8" — or the long form "Showing 3 of 8 transfers · 2 fees". */
|
|
735
|
+
children?: React.ReactNode;
|
|
736
|
+
className?: string;
|
|
737
|
+
}> = ({ filtered, children, className }) => (
|
|
738
|
+
<div className={['dt-filtered', filtered ? 'is-open' : '', className || ''].filter(Boolean).join(' ')} aria-hidden={!filtered}>
|
|
739
|
+
<div className="dt-filtered-inner" aria-live="polite">
|
|
740
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z" /></svg>
|
|
741
|
+
<span>{filtered ? children : null}</span>
|
|
742
|
+
</div>
|
|
743
|
+
</div>
|
|
744
|
+
);
|
|
745
|
+
|
|
746
|
+
/** The keyboard handle of a row whose click opens something (rowAction="open"):
|
|
747
|
+
the name rendered as a real <button> that looks like the text it replaces. It
|
|
748
|
+
stops propagation so the row's own onClick does not fire a second time. */
|
|
749
|
+
export const DTRowButton: React.FC<React.ButtonHTMLAttributes<HTMLButtonElement>> = ({ children, className, onClick, ...rest }) => (
|
|
750
|
+
<button
|
|
751
|
+
type="button"
|
|
752
|
+
className={['dt-row-button', className || ''].filter(Boolean).join(' ')}
|
|
753
|
+
onClick={(e) => { e.stopPropagation(); if (onClick) onClick(e); }}
|
|
698
754
|
{...rest}
|
|
699
755
|
>
|
|
700
756
|
{children}
|
|
701
|
-
</
|
|
757
|
+
</button>
|
|
702
758
|
);
|
|
703
759
|
|
|
704
760
|
/* Slot markers, set after both declarations exist. DataTable reads these to tell
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
export interface DateRangePreset { label: string; from: string; to: string; }
|
|
4
|
+
|
|
5
|
+
export interface DateRangePickerProps {
|
|
6
|
+
/** ISO date ("2026-03-10"); "" = open on that side. */
|
|
7
|
+
from: string;
|
|
8
|
+
to: string;
|
|
9
|
+
onChange: (from: string, to: string) => void;
|
|
10
|
+
placeholder?: string;
|
|
11
|
+
/** sm = 32px trigger, for a table toolbar. */
|
|
12
|
+
size?: 'sm' | 'md';
|
|
13
|
+
/** Render the panel in <body> (default true) — .dt-wrap clips overflow. */
|
|
14
|
+
portal?: boolean;
|
|
15
|
+
presets?: DateRangePreset[];
|
|
16
|
+
locale?: string;
|
|
17
|
+
className?: string;
|
|
18
|
+
'aria-label'?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export declare const DateRangePicker: React.FC<DateRangePickerProps>;
|
|
22
|
+
export declare function defaultDatePresets(today?: Date): DateRangePreset[];
|
|
23
|
+
export declare function formatDateRange(from: string, to: string, locale?: string): string;
|
|
24
|
+
export default DateRangePicker;
|