@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.
@@ -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
- {icon != null ? <span className="combobox-trigger-icon">{icon}</span> : null}
275
- <span className="combobox-value">
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: boolean;
130
- onToggle: () => void;
131
- controls: string;
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: boolean;
435
- onToggle: () => void;
436
- controls: string;
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 pointer affordance. */
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
- <tr
691
- className={[kind === 'detail' ? 'has-detail' : 'has-subrows', onToggle ? 'is-collapsible is-clickable' : '', open ? 'is-open' : '', className || ''].filter(Boolean).join(' ')}
692
- role={onToggle ? 'button' : undefined}
693
- tabIndex={onToggle ? 0 : undefined}
694
- aria-expanded={onToggle ? !!open : undefined}
695
- aria-controls={controls}
696
- onClick={onToggle}
697
- onKeyDown={onToggle ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle(); } } : undefined}
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
- </tr>
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;