@attrus-ds/core 1.0.18 → 1.0.19
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 +69 -0
- package/dist/css/components/callout.css +7 -1
- package/dist/css/components/cards.css +6 -2
- package/dist/css/components/inputs.css +215 -3
- package/dist/css/components/sidebar.css +2 -1
- package/dist/css/components/table.css +74 -21
- package/dist/react/DataTable/DataTable.d.ts +5 -0
- package/dist/react/DataTable/DataTable.tsx +23 -0
- package/dist/react/Input/Input.d.ts +10 -0
- package/dist/react/Input/Input.tsx +92 -10
- package/dist/react/SearchPill/SearchPill.d.ts +6 -1
- package/dist/react/SearchPill/SearchPill.tsx +54 -4
- package/package.json +1 -1
|
@@ -330,3 +330,72 @@
|
|
|
330
330
|
.btn-group .btn + .btn { margin-left: calc(var(--border-hairline) * -1); }
|
|
331
331
|
.btn-group .btn:focus-visible,
|
|
332
332
|
.btn-group .btn:hover { z-index: 1; }
|
|
333
|
+
|
|
334
|
+
/* Seam for borderless fills. Primary, tertiary, ghost, danger and inverse carry a
|
|
335
|
+
TRANSPARENT border, so with the one-hairline overlap above there is nothing to
|
|
336
|
+
divide the segments — the group reads as one long button. The divider is drawn
|
|
337
|
+
in currentColor so it contrasts with any fill without a per-variant token.
|
|
338
|
+
|
|
339
|
+
These variants also drop the overlap. It exists to merge two visible borders
|
|
340
|
+
into one; with no border to merge it only stacks the two fills on a 1px strip,
|
|
341
|
+
and on a translucent fill (tertiary, ghost hover, the on-inverse surfaces) that
|
|
342
|
+
strip paints twice — a divider visibly thicker than the bordered variants'. */
|
|
343
|
+
.btn-group .btn-primary + .btn-primary,
|
|
344
|
+
.btn-group .btn-tertiary + .btn-tertiary,
|
|
345
|
+
.btn-group .btn-ghost + .btn-ghost,
|
|
346
|
+
.btn-group .btn-danger + .btn-danger,
|
|
347
|
+
.btn-group .btn-inverse + .btn-inverse {
|
|
348
|
+
margin-left: 0;
|
|
349
|
+
box-shadow: inset var(--border-hairline) 0 0 0 color-mix(in srgb, currentColor 22%, transparent);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/* Selected segment. One rule for every variant: an overlay of the variant's own
|
|
353
|
+
text colour. It darkens a light fill and lightens a dark one, so "selected"
|
|
354
|
+
stays legible on primary, secondary, danger and inverse alike.
|
|
355
|
+
|
|
356
|
+
Drawn as an INSET shadow, not a background layer: a background paints under
|
|
357
|
+
the border too, so on a bordered variant the selected segment's hairlines
|
|
358
|
+
picked up the tint and read thicker than the rest of the group. An inset
|
|
359
|
+
shadow stays inside the border and leaves every seam the same weight.
|
|
360
|
+
aria-pressed is the state: it is what a screen reader announces for a toggle
|
|
361
|
+
in a segmented control, and styling from it keeps markup and look in step. */
|
|
362
|
+
.btn-group .btn[aria-pressed="true"] {
|
|
363
|
+
box-shadow: inset 0 0 0 999px color-mix(in srgb, currentColor 14%, transparent);
|
|
364
|
+
font-weight: var(--w-bold);
|
|
365
|
+
/* The overlay stops at the border, and the next segment overlaps this one by a
|
|
366
|
+
hairline — so without this the selected segment ended in a 1px light strip
|
|
367
|
+
(its own untinted border, covered by the neighbour's fill). Lifting it above
|
|
368
|
+
its neighbours and drawing its border in the seam colour closes the edge:
|
|
369
|
+
the selected segment reads as one tinted block with a clean outline. */
|
|
370
|
+
position: relative;
|
|
371
|
+
z-index: 1;
|
|
372
|
+
border-color: color-mix(in srgb, currentColor 22%, transparent);
|
|
373
|
+
}
|
|
374
|
+
/* Borderless variants paint the overlay as a background instead. Their 1px
|
|
375
|
+
border is TRANSPARENT, and an inset shadow stops at the border — so it left a
|
|
376
|
+
1px untinted ring around the selected segment, most visible on its right edge.
|
|
377
|
+
With no hairline to thicken, tinting the border box is exactly right here. */
|
|
378
|
+
.btn-group .btn-primary[aria-pressed="true"],
|
|
379
|
+
.btn-group .btn-tertiary[aria-pressed="true"],
|
|
380
|
+
.btn-group .btn-ghost[aria-pressed="true"],
|
|
381
|
+
.btn-group .btn-danger[aria-pressed="true"],
|
|
382
|
+
.btn-group .btn-inverse[aria-pressed="true"] {
|
|
383
|
+
background-image: linear-gradient(color-mix(in srgb, currentColor 14%, transparent), color-mix(in srgb, currentColor 14%, transparent));
|
|
384
|
+
/* borderless: keep the border transparent — the fill already covers it */
|
|
385
|
+
border-color: transparent;
|
|
386
|
+
}
|
|
387
|
+
/* The tinted block is its own divider on borderless variants, so neither it nor
|
|
388
|
+
the segment after it draws a seam. A seam there sits one pixel inside the
|
|
389
|
+
neighbour's transparent border and leaves a light pixel between the tint and
|
|
390
|
+
the line — a doubled edge on both sides of the selected segment. */
|
|
391
|
+
.btn-group .btn-primary[aria-pressed="true"],
|
|
392
|
+
.btn-group .btn-tertiary[aria-pressed="true"],
|
|
393
|
+
.btn-group .btn-ghost[aria-pressed="true"],
|
|
394
|
+
.btn-group .btn-danger[aria-pressed="true"],
|
|
395
|
+
.btn-group .btn-inverse[aria-pressed="true"],
|
|
396
|
+
.btn-group .btn-primary[aria-pressed="true"] + .btn-primary,
|
|
397
|
+
.btn-group .btn-tertiary[aria-pressed="true"] + .btn-tertiary,
|
|
398
|
+
.btn-group .btn-ghost[aria-pressed="true"] + .btn-ghost,
|
|
399
|
+
.btn-group .btn-danger[aria-pressed="true"] + .btn-danger,
|
|
400
|
+
.btn-group .btn-inverse[aria-pressed="true"] + .btn-inverse { box-shadow: none; }
|
|
401
|
+
|
|
@@ -201,7 +201,13 @@
|
|
|
201
201
|
padding: var(--space-3) var(--space-6);
|
|
202
202
|
border-left: 3px solid var(--callout-tint); /* accent rule for scannability */
|
|
203
203
|
}
|
|
204
|
-
|
|
204
|
+
/* The base rule pins the icon (and the dismiss) to the first line with
|
|
205
|
+
align-self: flex-start — right for a multi-line callout. A bar centres its
|
|
206
|
+
row with align-items: center, but align-self on the item beats align-items
|
|
207
|
+
on the container, so without resetting it here the icon stays at the top
|
|
208
|
+
while the text and the actions centre (18px vs 25px in a 50px bar). */
|
|
209
|
+
.callout-bar > i, .callout-bar > svg, .callout-bar .callout-icon { margin-top: 0; align-self: center; }
|
|
210
|
+
.callout-bar .callout-dismiss { align-self: center; }
|
|
205
211
|
.callout-bar .callout-content { flex-direction: row; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
|
|
206
212
|
.callout-bar .callout-actions { margin-top: 0; margin-left: auto; }
|
|
207
213
|
.callout-bar .callout-dismiss { align-self: center; margin-top: 0; }
|
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
background: var(--color-background-surface);
|
|
14
14
|
border: 1px solid var(--color-border-default);
|
|
15
15
|
border-radius: var(--shape-container);
|
|
16
|
-
|
|
16
|
+
/* Flat at rest: the hairline border separates the card from the canvas.
|
|
17
|
+
Shadow is reserved for what floats (.raised, .floating) and for the
|
|
18
|
+
interaction feedback on .interactive:hover. */
|
|
19
|
+
box-shadow: none;
|
|
17
20
|
color: var(--color-foreground-default);
|
|
18
21
|
font-family: var(--font-sans);
|
|
19
22
|
/* base padding kicks in only when the card holds direct content
|
|
@@ -258,7 +261,8 @@
|
|
|
258
261
|
}
|
|
259
262
|
.card.interactive:active {
|
|
260
263
|
transform: translateY(0);
|
|
261
|
-
|
|
264
|
+
/* Press returns the card to its flat resting state */
|
|
265
|
+
box-shadow: none;
|
|
262
266
|
}
|
|
263
267
|
.card.interactive:focus-visible {
|
|
264
268
|
outline: none;
|
|
@@ -281,6 +281,95 @@ textarea.input.input-lg { height: auto; min-height: 120px; padding: var(--space-
|
|
|
281
281
|
.group.is-error { border-color: var(--color-border-danger); }
|
|
282
282
|
.group.is-error:focus-within { box-shadow: var(--shadow-focus-danger); }
|
|
283
283
|
|
|
284
|
+
/* ----------------------------------------------------------------
|
|
285
|
+
5b. COMBO — an interactive segment attached to the field
|
|
286
|
+
----------------------------------------------------------------
|
|
287
|
+
.fix is static text ("USD", ".com"). When the attached cell is a CONTROL —
|
|
288
|
+
a unit/currency/country selector before the value, or an action after it
|
|
289
|
+
(Copy, Apply, Search) — it needs its own hit area, hover and focus ring, and
|
|
290
|
+
it must keep the group's single outline. Two segment types:
|
|
291
|
+
|
|
292
|
+
.fix-select.l|.r native <select> + .chev, reads as a cell of the field
|
|
293
|
+
.fix-btn.l|.r <button>; add .is-primary when it is THE action
|
|
294
|
+
|
|
295
|
+
The same segments attach to .pill-search (see §6). Both sit inside .group, so they inherit its height, radius clip and the one
|
|
296
|
+
focus ring on :focus-within; their own focus is an inset ring so it cannot
|
|
297
|
+
be clipped by the group's overflow.
|
|
298
|
+
---------------------------------------------------------------- */
|
|
299
|
+
:is(.group, .pill-search) > .fix-select,
|
|
300
|
+
:is(.group, .pill-search) > .fix-btn {
|
|
301
|
+
flex-shrink: 0;
|
|
302
|
+
background: var(--color-background-subtle);
|
|
303
|
+
transition: background var(--transition-fast), color var(--transition-fast);
|
|
304
|
+
}
|
|
305
|
+
:is(.group, .pill-search) > .fix-select.l,
|
|
306
|
+
:is(.group, .pill-search) > .fix-btn.l { border-right: var(--border-hairline) solid var(--color-border-default); }
|
|
307
|
+
:is(.group, .pill-search) > .fix-select.r,
|
|
308
|
+
:is(.group, .pill-search) > .fix-btn.r { border-left: var(--border-hairline) solid var(--color-border-default); }
|
|
309
|
+
|
|
310
|
+
:is(.group, .pill-search) > .fix-select { position: relative; display: flex; align-items: stretch; }
|
|
311
|
+
:is(.group, .pill-search) > .fix-select > select {
|
|
312
|
+
appearance: none;
|
|
313
|
+
-webkit-appearance: none;
|
|
314
|
+
height: 100%;
|
|
315
|
+
margin: 0;
|
|
316
|
+
border: 0;
|
|
317
|
+
outline: none;
|
|
318
|
+
background: transparent;
|
|
319
|
+
/* room on the right for the chevron */
|
|
320
|
+
padding: 0 calc(var(--space-4) + var(--icon-xs) + var(--space-1)) 0 var(--space-4);
|
|
321
|
+
color: var(--color-foreground-default);
|
|
322
|
+
font-family: var(--font-mono);
|
|
323
|
+
font-size-adjust: var(--font-mono-adjust);
|
|
324
|
+
font-size: var(--fs-sm);
|
|
325
|
+
cursor: pointer;
|
|
326
|
+
}
|
|
327
|
+
:is(.group, .pill-search) > .fix-select > .chev {
|
|
328
|
+
position: absolute;
|
|
329
|
+
top: 50%;
|
|
330
|
+
right: var(--space-3);
|
|
331
|
+
width: var(--icon-xs);
|
|
332
|
+
height: var(--icon-xs);
|
|
333
|
+
transform: translateY(-50%);
|
|
334
|
+
color: var(--color-foreground-muted);
|
|
335
|
+
pointer-events: none;
|
|
336
|
+
}
|
|
337
|
+
:is(.group, .pill-search) > .fix-select:hover { background: var(--color-background-hover); }
|
|
338
|
+
:is(.group, .pill-search) > .fix-select > select:focus-visible { box-shadow: inset 0 0 0 2px var(--color-border-focus); }
|
|
339
|
+
|
|
340
|
+
:is(.group, .pill-search) > .fix-btn {
|
|
341
|
+
display: inline-flex;
|
|
342
|
+
align-items: center;
|
|
343
|
+
justify-content: center;
|
|
344
|
+
gap: var(--space-2);
|
|
345
|
+
height: 100%;
|
|
346
|
+
margin: 0;
|
|
347
|
+
padding: 0 var(--space-4);
|
|
348
|
+
border-top: 0;
|
|
349
|
+
border-bottom: 0;
|
|
350
|
+
color: var(--color-foreground-secondary);
|
|
351
|
+
font-family: var(--font-sans);
|
|
352
|
+
font-size: var(--fs-sm);
|
|
353
|
+
font-weight: var(--w-regular);
|
|
354
|
+
white-space: nowrap;
|
|
355
|
+
cursor: pointer;
|
|
356
|
+
}
|
|
357
|
+
:is(.group, .pill-search) > .fix-btn.l { border-left: 0; }
|
|
358
|
+
:is(.group, .pill-search) > .fix-btn.r { border-right: 0; }
|
|
359
|
+
:is(.group, .pill-search) > .fix-btn svg,
|
|
360
|
+
:is(.group, .pill-search) > .fix-btn i { width: var(--icon-sm); height: var(--icon-sm); stroke-width: var(--icon-stroke); }
|
|
361
|
+
:is(.group, .pill-search) > .fix-btn:hover { background: var(--color-background-hover); color: var(--color-foreground-default); }
|
|
362
|
+
:is(.group, .pill-search) > .fix-btn:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--color-border-focus); }
|
|
363
|
+
:is(.group, .pill-search) > .fix-btn:disabled { cursor: not-allowed; color: var(--color-foreground-disabled); background: var(--color-background-sunken); }
|
|
364
|
+
/* The field's one decisive action — filled, so it is found without reading */
|
|
365
|
+
:is(.group, .pill-search) > .fix-btn.is-primary {
|
|
366
|
+
background: var(--color-brand-solid);
|
|
367
|
+
color: var(--color-foreground-on-solid);
|
|
368
|
+
border-color: var(--color-brand-solid);
|
|
369
|
+
}
|
|
370
|
+
:is(.group, .pill-search) > .fix-btn.is-primary:hover { background: var(--color-brand-solid-hover); color: var(--color-foreground-on-solid); }
|
|
371
|
+
:is(.group, .pill-search) > .fix-btn.is-primary:focus-visible { box-shadow: inset 0 0 0 2px var(--color-foreground-on-solid); }
|
|
372
|
+
|
|
284
373
|
|
|
285
374
|
/* ----------------------------------------------------------------
|
|
286
375
|
6. PILL SEARCH (⌘K-style)
|
|
@@ -335,19 +424,74 @@ textarea.input.input-lg { height: auto; min-height: 120px; padding: var(--space-
|
|
|
335
424
|
}
|
|
336
425
|
.pill-search > input::placeholder { color: var(--color-foreground-placeholder); }
|
|
337
426
|
.kbd {
|
|
427
|
+
/* A fixed box, not text with padding. With padding alone the chip was 19px
|
|
428
|
+
tall on a 15px line box, the ⌘ glyph and the letter sat on different
|
|
429
|
+
optical baselines, and caps letter-spacing added trailing space that pushed
|
|
430
|
+
the pair off-centre. Height + min-width + centred flex fix all three. */
|
|
338
431
|
display: inline-flex;
|
|
339
432
|
align-items: center;
|
|
433
|
+
justify-content: center;
|
|
434
|
+
gap: 2px;
|
|
435
|
+
box-sizing: border-box;
|
|
436
|
+
height: 20px;
|
|
437
|
+
min-width: 20px;
|
|
438
|
+
padding: 0 var(--space-2);
|
|
439
|
+
flex-shrink: 0;
|
|
340
440
|
font-family: var(--font-mono);
|
|
341
441
|
font-size-adjust: var(--font-mono-adjust);
|
|
342
442
|
font-size: var(--fs-xxs);
|
|
343
443
|
font-weight: var(--w-regular);
|
|
444
|
+
line-height: 1;
|
|
445
|
+
letter-spacing: 0;
|
|
344
446
|
color: var(--color-foreground-muted);
|
|
345
447
|
background: var(--color-background-subtle);
|
|
346
|
-
border:
|
|
448
|
+
border: var(--border-hairline) solid var(--color-border-default);
|
|
347
449
|
border-radius: var(--radius-2xs);
|
|
348
|
-
padding: 1px var(--space-2);
|
|
349
|
-
letter-spacing: var(--ls-caps);
|
|
350
450
|
}
|
|
451
|
+
/* Split keys: <span class="kbd"><kbd>⌘</kbd><kbd>K</kbd></span> — each glyph
|
|
452
|
+
gets its own centred cell, so a symbol and a letter share one baseline. */
|
|
453
|
+
.kbd > kbd {
|
|
454
|
+
display: inline-flex;
|
|
455
|
+
align-items: center;
|
|
456
|
+
justify-content: center;
|
|
457
|
+
min-width: 1ch;
|
|
458
|
+
font: inherit;
|
|
459
|
+
line-height: 1;
|
|
460
|
+
}
|
|
461
|
+
.pill-search-sm .kbd { height: 18px; min-width: 18px; padding: 0 var(--space-1); }
|
|
462
|
+
/* On a pill the trailing chip sits against a ROUND end, so the inset that
|
|
463
|
+
reads as even is the vertical one, not the field's horizontal padding:
|
|
464
|
+
(40 − 20) / 2 at md, (32 − 18) / 2 at sm. The border sits outside the
|
|
465
|
+
content height on both axes, so it cancels out — measured 11/11 and 8/8. */
|
|
466
|
+
.pill-search:has(> .kbd:last-child) { padding-right: calc((var(--control-h-md) - 20px) / 2); }
|
|
467
|
+
.pill-search-sm:has(> .kbd:last-child) { padding-right: calc((var(--control-h-sm) - 18px) / 2); }
|
|
468
|
+
|
|
469
|
+
/* Combo on the pill — a selector or button flush with a rounded end. The pill
|
|
470
|
+
clips them to its radius; the end the segment touches loses its padding, and
|
|
471
|
+
the segment adds a little extra on that side so its content clears the curve. */
|
|
472
|
+
.pill-search:has(> .fix-select, > .fix-btn) { overflow: hidden; }
|
|
473
|
+
.pill-search:has(> :is(.fix-select, .fix-btn):first-child) { padding-left: 0; }
|
|
474
|
+
.pill-search:has(> :is(.fix-select, .fix-btn):last-child) { padding-right: 0; }
|
|
475
|
+
.pill-search > .fix-select,
|
|
476
|
+
.pill-search > .fix-btn { align-self: stretch; height: auto; }
|
|
477
|
+
.pill-search > .fix-select:first-child > select { padding-left: var(--space-5); }
|
|
478
|
+
.pill-search > .fix-select:last-child > select { padding-right: calc(var(--space-5) + var(--icon-xs) + var(--space-1)); }
|
|
479
|
+
.pill-search > .fix-select:last-child > .chev { right: var(--space-4); }
|
|
480
|
+
.pill-search > .fix-btn:first-child { padding-left: var(--space-5); }
|
|
481
|
+
.pill-search > .fix-btn:last-child { padding-right: var(--space-5); }
|
|
482
|
+
/* The segment's own corners must follow the pill's round end. Clipping by the
|
|
483
|
+
pill's overflow only trims the paint OUTSIDE the curve — the inset focus ring
|
|
484
|
+
and the pressed fill are drawn on the segment's box, which was square, so they
|
|
485
|
+
showed straight corners inside a rounded field. Rounding the outer side of the
|
|
486
|
+
segment (and of the select it wraps) makes every state trace the pill. */
|
|
487
|
+
.pill-search > .fix-btn:first-child,
|
|
488
|
+
.pill-search > .fix-select:first-child,
|
|
489
|
+
.pill-search > .fix-select:first-child > select { border-radius: var(--radius-pill) 0 0 var(--radius-pill); }
|
|
490
|
+
.pill-search > .fix-btn:last-child,
|
|
491
|
+
.pill-search > .fix-select:last-child,
|
|
492
|
+
.pill-search > .fix-select:last-child > select { border-radius: 0 var(--radius-pill) var(--radius-pill) 0; }
|
|
493
|
+
.pill-search-sm > .fix-btn,
|
|
494
|
+
.pill-search-sm > .fix-select > select { font-size: var(--fs-xs); }
|
|
351
495
|
|
|
352
496
|
|
|
353
497
|
/* ----------------------------------------------------------------
|
|
@@ -367,6 +511,74 @@ textarea.input.input-lg { height: auto; min-height: 120px; padding: var(--space-
|
|
|
367
511
|
}
|
|
368
512
|
.input.underline.is-error { border-bottom-color: var(--color-border-danger); }
|
|
369
513
|
|
|
514
|
+
/* ----------------------------------------------------------------
|
|
515
|
+
7b. GROUP SHAPES — the combo input in pill and underline form
|
|
516
|
+
----------------------------------------------------------------
|
|
517
|
+
.group is the rectangular field. Two shape modifiers carry the same combo
|
|
518
|
+
segments (.fix / .fix-select / .fix-btn) onto the other two field shapes, so
|
|
519
|
+
a page that uses pill or underline inputs does not have to drop back to a
|
|
520
|
+
rectangle the moment a field needs a selector or a button.
|
|
521
|
+
|
|
522
|
+
.group.is-pill rounded ends; end segments trace the curve
|
|
523
|
+
.group.underline bottom rule only; segments lose fill and seams
|
|
524
|
+
---------------------------------------------------------------- */
|
|
525
|
+
.group.is-pill { border-radius: var(--radius-pill); }
|
|
526
|
+
.group.is-pill > input:first-child { padding-left: var(--space-5); }
|
|
527
|
+
.group.is-pill > input:last-child { padding-right: var(--space-5); }
|
|
528
|
+
.group.is-pill > .fix:first-child,
|
|
529
|
+
.group.is-pill > .fix-btn:first-child { padding-left: var(--space-5); }
|
|
530
|
+
.group.is-pill > .fix:last-child,
|
|
531
|
+
.group.is-pill > .fix-btn:last-child { padding-right: var(--space-5); }
|
|
532
|
+
.group.is-pill > .fix-select:first-child > select { padding-left: var(--space-5); }
|
|
533
|
+
/* End segments round their outer side, so focus rings and fills trace the pill
|
|
534
|
+
instead of drawing square corners inside it. */
|
|
535
|
+
.group.is-pill > :is(.fix, .fix-btn, .fix-select):first-child,
|
|
536
|
+
.group.is-pill > .fix-select:first-child > select { border-radius: var(--radius-pill) 0 0 var(--radius-pill); }
|
|
537
|
+
.group.is-pill > :is(.fix, .fix-btn, .fix-select):last-child,
|
|
538
|
+
.group.is-pill > .fix-select:last-child > select { border-radius: 0 var(--radius-pill) var(--radius-pill) 0; }
|
|
539
|
+
|
|
540
|
+
/* Underline: the rule IS the field. Segments sit on the canvas, keep their text
|
|
541
|
+
and chevron, and are separated from the value by space instead of a seam — a
|
|
542
|
+
vertical line on a borderless field reads as a stray mark. */
|
|
543
|
+
.group.underline {
|
|
544
|
+
border: 0;
|
|
545
|
+
border-bottom: var(--border-hairline) solid var(--color-border-default);
|
|
546
|
+
border-radius: 0;
|
|
547
|
+
background: transparent;
|
|
548
|
+
gap: var(--space-3);
|
|
549
|
+
}
|
|
550
|
+
.group.underline:focus-within { border-bottom-color: var(--color-border-focus); box-shadow: none; }
|
|
551
|
+
.group.underline.is-error { border-bottom-color: var(--color-border-danger); }
|
|
552
|
+
.group.underline.is-error:focus-within { box-shadow: none; }
|
|
553
|
+
.group.underline > input { padding: 0; }
|
|
554
|
+
.group.underline > :is(.fix, .fix-select, .fix-btn) {
|
|
555
|
+
background: transparent;
|
|
556
|
+
border-left: 0;
|
|
557
|
+
border-right: 0;
|
|
558
|
+
}
|
|
559
|
+
.group.underline > .fix { padding: 0; }
|
|
560
|
+
.group.underline > .fix-select > select { padding-left: 0; }
|
|
561
|
+
.group.underline > .fix-btn { padding: 0 var(--space-2); border-radius: var(--radius-xs); }
|
|
562
|
+
.group.underline > .fix-btn:last-child { margin-right: calc(-1 * var(--space-2)); }
|
|
563
|
+
.group.underline > .fix-btn:hover { background: var(--color-background-hover); }
|
|
564
|
+
/* A filled block on a quiet field shouts — the primary action becomes brand text */
|
|
565
|
+
.group.underline > .fix-btn.is-primary { background: transparent; color: var(--color-foreground-brand); font-weight: var(--w-bold); }
|
|
566
|
+
.group.underline > .fix-btn.is-primary:hover { background: var(--color-background-hover); color: var(--color-foreground-brand); }
|
|
567
|
+
/* Focus on an underline field is the rule turning focus-blue — nothing else. The
|
|
568
|
+
segments' inset rings (right on a boxed field) drew a rectangle on a field that
|
|
569
|
+
has no box, so they are dropped here. Which segment holds focus is still shown,
|
|
570
|
+
by underlining its own label in the focus colour: the one mark this shape
|
|
571
|
+
already speaks. */
|
|
572
|
+
.group.underline > .fix-select > select:focus-visible,
|
|
573
|
+
.group.underline > .fix-btn:focus-visible,
|
|
574
|
+
.group.underline > .fix-btn.is-primary:focus-visible {
|
|
575
|
+
box-shadow: none;
|
|
576
|
+
text-decoration: underline;
|
|
577
|
+
text-decoration-color: var(--color-border-focus);
|
|
578
|
+
text-decoration-thickness: 2px;
|
|
579
|
+
text-underline-offset: 4px;
|
|
580
|
+
}
|
|
581
|
+
|
|
370
582
|
|
|
371
583
|
/* ----------------------------------------------------------------
|
|
372
584
|
8. CHIPS — multi-select / token input
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
background: var(--color-background-surface);
|
|
21
21
|
border: 1px solid var(--color-border-default);
|
|
22
22
|
border-radius: var(--radius-md);
|
|
23
|
-
|
|
23
|
+
/* Flat at rest — the sidebar does not float over the page */
|
|
24
|
+
box-shadow: none;
|
|
24
25
|
display: flex; flex-direction: column;
|
|
25
26
|
overflow: hidden;
|
|
26
27
|
}
|
|
@@ -56,18 +56,22 @@
|
|
|
56
56
|
border: 1px solid var(--color-border-default);
|
|
57
57
|
border-radius: var(--shape-container);
|
|
58
58
|
overflow: hidden;
|
|
59
|
-
|
|
59
|
+
/* Flat at rest — the hairline border is the separation */
|
|
60
|
+
box-shadow: none;
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
.dt-toolbar {
|
|
63
64
|
display: flex;
|
|
64
65
|
align-items: center;
|
|
65
66
|
gap: var(--space-4);
|
|
66
|
-
/*
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
/* Height comes from a vertical inset around the controls, not a fixed box. A
|
|
68
|
+
fixed 52px left 6px above and below a 40px field — the control read as
|
|
69
|
+
jammed against the rules. The inset now guarantees breathing room for any
|
|
70
|
+
control size, and min-height keeps the bar steady when it holds only text,
|
|
71
|
+
so the selecting state (same controls) still does not make the table jump.
|
|
72
|
+
Box on the element, not a descendant selector, so it holds anywhere. */
|
|
73
|
+
min-height: calc(var(--control-h-sm) + 2 * var(--space-5) + var(--border-hairline));
|
|
74
|
+
padding: var(--space-5) var(--space-5);
|
|
71
75
|
box-sizing: border-box;
|
|
72
76
|
border-bottom: 1px solid var(--color-border-subtle);
|
|
73
77
|
background: var(--color-background-surface);
|
|
@@ -645,7 +649,8 @@
|
|
|
645
649
|
card would measure 100% PLUS the gutters and hang off the right edge.
|
|
646
650
|
auto lets it shrink to the space the margins leave. */
|
|
647
651
|
width: auto;
|
|
648
|
-
|
|
652
|
+
/* Stacked cards are resting surfaces too: border only */
|
|
653
|
+
box-shadow: none;
|
|
649
654
|
}
|
|
650
655
|
.dt-responsive .dt tbody tr:last-child { margin-bottom: var(--space-3); border-bottom: 1px solid var(--color-border-default); }
|
|
651
656
|
|
|
@@ -1203,11 +1208,14 @@ a.dt-detail-card:focus-visible { outline: none; box-shadow: var(--shadow-focus);
|
|
|
1203
1208
|
display: flex;
|
|
1204
1209
|
align-items: center;
|
|
1205
1210
|
gap: var(--space-4);
|
|
1206
|
-
height:
|
|
1207
|
-
padding:
|
|
1211
|
+
min-height: calc(var(--control-h-sm) + 2 * var(--space-5) + var(--border-hairline));
|
|
1212
|
+
padding: var(--space-5) var(--space-5);
|
|
1208
1213
|
box-sizing: border-box;
|
|
1209
1214
|
}
|
|
1210
|
-
|
|
1215
|
+
/* Dense: tighter inset, same principle — 12px above and below the controls
|
|
1216
|
+
(16px at md). --space-4 is 12px; the earlier --space-3 was only 8px, which
|
|
1217
|
+
is what left the dense bar jammed. */
|
|
1218
|
+
.dt-toolbar.dt-toolbar-sm { min-height: calc(var(--control-h-sm) + 2 * var(--space-4) + var(--border-hairline)); padding: var(--space-4) var(--space-4); }
|
|
1211
1219
|
.dt-toolbar-lead { display: inline-flex; align-items: baseline; gap: var(--space-2); min-width: 0; }
|
|
1212
1220
|
.dt-toolbar-filters { display: inline-flex; align-items: center; gap: var(--space-2); min-width: 0; flex: 1 1 auto; }
|
|
1213
1221
|
.dt-toolbar-actions { margin-left: auto; display: inline-flex; align-items: center; gap: var(--space-2); flex-shrink: 0; }
|
|
@@ -1263,19 +1271,22 @@ a.dt-detail-card:focus-visible { outline: none; box-shadow: var(--shadow-focus);
|
|
|
1263
1271
|
.dt-wrap > .dt-scroll-y:has(+ .dt-pagination) { border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
|
|
1264
1272
|
|
|
1265
1273
|
/* ----------------------------------------------------------------
|
|
1266
|
-
FRAME OWNERSHIP
|
|
1274
|
+
FRAME OWNERSHIP
|
|
1267
1275
|
----------------------------------------------------------------
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1276
|
+
Product rule: a table ALWAYS has its border — with a toolbar, with a pager,
|
|
1277
|
+
with both or with neither. The base .dt-wrap rule draws it, and a toolbar or
|
|
1278
|
+
pager does not replace it: they sit inside the frame and divide themselves
|
|
1279
|
+
from the body with their own seam (toolbar border-bottom, pager border-top).
|
|
1280
|
+
|
|
1281
|
+
An earlier version zeroed the wrap border whenever a toolbar or pager was
|
|
1282
|
+
present, on the theory that "the pager closes the bottom and the toolbar the
|
|
1283
|
+
top". But it zeroed all FOUR sides, so every paginated table lost its sides
|
|
1284
|
+
and top and was left with a shadow and a bottom rule — Counterparties had a
|
|
1285
|
+
frame and Payment Requests did not.
|
|
1286
|
+
|
|
1287
|
+
The one real exception is a table sitting inside a container that is already
|
|
1288
|
+
the frame; there the wrap border would be a second contour.
|
|
1272
1289
|
---------------------------------------------------------------- */
|
|
1273
|
-
.dt-wrap:has(> .dt-toolbar),
|
|
1274
|
-
.dt-wrap:has(> .dt-pagination) { border: 0; }
|
|
1275
|
-
/* Chrome carries the edge instead */
|
|
1276
|
-
.dt-wrap:has(> .dt-toolbar) > .dt-toolbar { border-top: var(--border-hairline) solid var(--color-border-default); }
|
|
1277
|
-
.dt-wrap:has(> .dt-pagination) > .dt-pagination { border-bottom: var(--border-hairline) solid var(--color-border-default); }
|
|
1278
|
-
/* Inside a card, drawer or modal the container is the frame */
|
|
1279
1290
|
.demo-card-canvas .dt-wrap,
|
|
1280
1291
|
.drawer-body .dt-wrap,
|
|
1281
1292
|
.modal-body .dt-wrap { border: 0; }
|
|
@@ -1327,3 +1338,45 @@ a.dt-detail-card:focus-visible { outline: none; box-shadow: var(--shadow-focus);
|
|
|
1327
1338
|
text-decoration-color: var(--color-foreground-inverse-muted);
|
|
1328
1339
|
color: var(--color-foreground-inverse-secondary);
|
|
1329
1340
|
}
|
|
1341
|
+
|
|
1342
|
+
/* ----------------------------------------------------------------
|
|
1343
|
+
OUTLINE PILL — .dt-outline
|
|
1344
|
+
----------------------------------------------------------------
|
|
1345
|
+
A hairline pill that WRAPS an inline combo (flag + label, icon + code,
|
|
1346
|
+
corridor) so it reads as one token in the cell rather than loose parts.
|
|
1347
|
+
It is a container, not a status: no fill, no tone — tone belongs to .pill.
|
|
1348
|
+
Use it where the combo is a categorical value the eye compares down the
|
|
1349
|
+
column (market, rail, jurisdiction). Do not wrap the row's own name: that is
|
|
1350
|
+
the identity of the row, not a value in it.
|
|
1351
|
+
---------------------------------------------------------------- */
|
|
1352
|
+
.dt-outline {
|
|
1353
|
+
display: inline-flex;
|
|
1354
|
+
align-items: center;
|
|
1355
|
+
gap: var(--space-2);
|
|
1356
|
+
height: 24px;
|
|
1357
|
+
padding: 0 var(--space-3) 0 var(--space-2);
|
|
1358
|
+
border: var(--border-hairline) solid var(--color-border-default);
|
|
1359
|
+
border-radius: var(--radius-pill);
|
|
1360
|
+
background: transparent;
|
|
1361
|
+
color: var(--color-foreground-default);
|
|
1362
|
+
font-family: var(--font-sans);
|
|
1363
|
+
font-size: var(--fs-xs);
|
|
1364
|
+
font-weight: var(--w-regular);
|
|
1365
|
+
line-height: 1;
|
|
1366
|
+
white-space: nowrap;
|
|
1367
|
+
box-sizing: border-box;
|
|
1368
|
+
vertical-align: middle;
|
|
1369
|
+
}
|
|
1370
|
+
/* No leading media → balanced inset */
|
|
1371
|
+
.dt-outline:not(:has(> .flag, > svg, > i, > .ic-mini)) { padding: 0 var(--space-3); }
|
|
1372
|
+
.dt-outline > .flag { width: 16px; height: 16px; border-radius: var(--radius-pill); flex-shrink: 0; }
|
|
1373
|
+
.dt-outline > svg,
|
|
1374
|
+
.dt-outline > i { width: var(--icon-xs); height: var(--icon-xs); stroke-width: var(--icon-stroke); color: var(--color-foreground-muted); flex-shrink: 0; }
|
|
1375
|
+
/* A mono code inside the pill (ISO, rail) keeps the pill's height */
|
|
1376
|
+
.dt-outline .code { font-family: var(--font-mono); font-size-adjust: var(--font-mono-adjust); font-size: var(--fs-xxs); color: var(--color-foreground-muted); letter-spacing: var(--ls-caps); }
|
|
1377
|
+
.dt-outline-sm { height: 20px; font-size: var(--fs-xxs); }
|
|
1378
|
+
.dt-outline-sm > .flag { width: 14px; height: 14px; }
|
|
1379
|
+
/* Several pills in one cell — a set (e.g. supported rails) */
|
|
1380
|
+
.dt-outline-set { display: inline-flex; flex-wrap: wrap; gap: var(--space-2); }
|
|
1381
|
+
.dt.on-inverse .dt-outline,
|
|
1382
|
+
.dark .dt .dt-outline { border-color: var(--color-border-inverse); color: var(--color-foreground-inverse-default); }
|
|
@@ -170,3 +170,8 @@ export declare const DTParentRow: React.FC<DTParentRowProps>;
|
|
|
170
170
|
export declare const DTPagination: React.FC<DTPaginationProps>;
|
|
171
171
|
|
|
172
172
|
export default DataTable;
|
|
173
|
+
|
|
174
|
+
/** Outline pill — .dt-outline. Wraps flag/icon + label (+ mono code) as one token. */
|
|
175
|
+
export declare const DTOutline: React.FC<{ flag?: string; icon?: React.ReactNode; code?: React.ReactNode; size?: 'sm' | 'md'; children?: React.ReactNode } & React.HTMLAttributes<HTMLSpanElement>>;
|
|
176
|
+
/** Set of outline pills in one cell — .dt-outline-set. */
|
|
177
|
+
export declare const DTOutlineSet: React.FC<React.HTMLAttributes<HTMLSpanElement>>;
|
|
@@ -285,6 +285,29 @@ export function DataTable<T>({
|
|
|
285
285
|
);
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
+
|
|
289
|
+
/** Outline pill — `.dt-outline`: a hairline pill that wraps an inline combo
|
|
290
|
+
(flag + label, icon + label, corridor) so it reads as one token. A container,
|
|
291
|
+
not a status: no fill, no tone. `code` renders a trailing mono ISO/rail code. */
|
|
292
|
+
export const DTOutline: React.FC<{
|
|
293
|
+
flag?: string;
|
|
294
|
+
icon?: React.ReactNode;
|
|
295
|
+
code?: React.ReactNode;
|
|
296
|
+
size?: 'sm' | 'md';
|
|
297
|
+
children?: React.ReactNode;
|
|
298
|
+
} & React.HTMLAttributes<HTMLSpanElement>> = ({ flag, icon, code, size = 'md', children, className, ...rest }) => (
|
|
299
|
+
<span className={['dt-outline', size === 'sm' ? 'dt-outline-sm' : '', className || ''].filter(Boolean).join(' ')} {...rest}>
|
|
300
|
+
{flag ? <img className="flag" src={flag} alt="" /> : icon}
|
|
301
|
+
{children}
|
|
302
|
+
{code != null ? <span className="code">{code}</span> : null}
|
|
303
|
+
</span>
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
/** Several outline pills in one cell — `.dt-outline-set`. */
|
|
307
|
+
export const DTOutlineSet: React.FC<React.HTMLAttributes<HTMLSpanElement>> = ({ className, ...rest }) => (
|
|
308
|
+
<span className={['dt-outline-set', className || ''].filter(Boolean).join(' ')} {...rest} />
|
|
309
|
+
);
|
|
310
|
+
|
|
288
311
|
export default DataTable;
|
|
289
312
|
|
|
290
313
|
/* ----------------------------------------------------------------
|
|
@@ -3,6 +3,10 @@ import * as React from 'react';
|
|
|
3
3
|
export type InputSize = 'sm' | 'md' | 'lg';
|
|
4
4
|
export type InputStatus = 'default' | 'error' | 'success' | 'warning';
|
|
5
5
|
|
|
6
|
+
export type InputAddon =
|
|
7
|
+
| { type: 'select'; options: Array<string | { value: string; label: React.ReactNode }>; value?: string; onChange?: (value: string) => void; 'aria-label': string }
|
|
8
|
+
| { type: 'button'; label: React.ReactNode; icon?: React.ReactNode; onClick?: () => void; primary?: boolean; disabled?: boolean; 'aria-label'?: string };
|
|
9
|
+
|
|
6
10
|
export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size' | 'prefix'> {
|
|
7
11
|
/** Field label (renders the .field wrapper). */
|
|
8
12
|
label?: React.ReactNode;
|
|
@@ -24,6 +28,12 @@ export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElem
|
|
|
24
28
|
prefix?: React.ReactNode;
|
|
25
29
|
/** Attached suffix cell — .group .fix.r (e.g. ".00"). */
|
|
26
30
|
suffix?: React.ReactNode;
|
|
31
|
+
/** Interactive segment before the field — selector or button (combo input). */
|
|
32
|
+
before?: InputAddon;
|
|
33
|
+
/** Interactive segment after the field — selector or button (combo input). */
|
|
34
|
+
after?: InputAddon;
|
|
35
|
+
/** Shape of an attached group: rectangle, rounded ends, or bottom rule only. */
|
|
36
|
+
groupShape?: 'default' | 'pill' | 'underline';
|
|
27
37
|
/** Dark-surface treatment (.on-inverse). */
|
|
28
38
|
onInverse?: boolean;
|
|
29
39
|
/** Quiet read-only: value only, no border (.is-readonly). */
|
|
@@ -8,6 +8,7 @@ import * as React from 'react';
|
|
|
8
8
|
* .field > label + control + .help (when label/help given)
|
|
9
9
|
* .affix.lead-pad/.trail-pad (when leadIcon/trailIcon given)
|
|
10
10
|
* .group > .fix.l + input + .fix.r (when prefix/suffix given)
|
|
11
|
+
* .group > .fix-select|.fix-btn + input (when before/after given — combo input)
|
|
11
12
|
*
|
|
12
13
|
* Note: prefix/suffix (.group) and leadIcon/trailIcon (.affix) are
|
|
13
14
|
* mutually exclusive — group wins if both are passed. Sizes apply to
|
|
@@ -17,6 +18,30 @@ import * as React from 'react';
|
|
|
17
18
|
export type InputSize = 'sm' | 'md' | 'lg';
|
|
18
19
|
export type InputStatus = 'default' | 'error' | 'success' | 'warning';
|
|
19
20
|
|
|
21
|
+
/** An interactive segment attached to the field (`.group > .fix-select` /
|
|
22
|
+
`.fix-btn`). Static text belongs in `prefix`/`suffix`; use an addon only
|
|
23
|
+
when the cell is a CONTROL — a qualifier the user picks, or an action that
|
|
24
|
+
consumes this field. */
|
|
25
|
+
export type InputAddon =
|
|
26
|
+
| {
|
|
27
|
+
type: 'select';
|
|
28
|
+
options: Array<string | { value: string; label: React.ReactNode }>;
|
|
29
|
+
value?: string;
|
|
30
|
+
onChange?: (value: string) => void;
|
|
31
|
+
/** Required: the select has no visible label of its own. */
|
|
32
|
+
'aria-label': string;
|
|
33
|
+
}
|
|
34
|
+
| {
|
|
35
|
+
type: 'button';
|
|
36
|
+
label: React.ReactNode;
|
|
37
|
+
icon?: React.ReactNode;
|
|
38
|
+
onClick?: () => void;
|
|
39
|
+
/** Filled — only when this is THE action of the field. */
|
|
40
|
+
primary?: boolean;
|
|
41
|
+
disabled?: boolean;
|
|
42
|
+
'aria-label'?: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
20
45
|
export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size' | 'prefix'> {
|
|
21
46
|
/** Field label (renders the .field wrapper). */
|
|
22
47
|
label?: React.ReactNode;
|
|
@@ -38,6 +63,14 @@ export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElem
|
|
|
38
63
|
prefix?: React.ReactNode;
|
|
39
64
|
/** Attached suffix cell — .group .fix.r (e.g. ".00"). */
|
|
40
65
|
suffix?: React.ReactNode;
|
|
66
|
+
/** Interactive segment BEFORE the field — selector or button (combo input).
|
|
67
|
+
Takes the slot prefix would use; pass one or the other, not both. */
|
|
68
|
+
before?: InputAddon;
|
|
69
|
+
/** Interactive segment AFTER the field — selector or button (combo input). */
|
|
70
|
+
after?: InputAddon;
|
|
71
|
+
/** Shape of an attached group (prefix/suffix/before/after): the rectangular
|
|
72
|
+
field, rounded ends (.group.is-pill) or bottom-rule only (.group.underline). */
|
|
73
|
+
groupShape?: 'default' | 'pill' | 'underline';
|
|
41
74
|
/** Dark-surface treatment (.on-inverse). */
|
|
42
75
|
onInverse?: boolean;
|
|
43
76
|
/** Quiet read-only: value only, no border (.is-readonly). */
|
|
@@ -46,6 +79,41 @@ export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElem
|
|
|
46
79
|
underline?: boolean;
|
|
47
80
|
}
|
|
48
81
|
|
|
82
|
+
function renderAddon(a: InputAddon, side: 'l' | 'r'): React.ReactNode {
|
|
83
|
+
if (a.type === 'select') {
|
|
84
|
+
return (
|
|
85
|
+
<span className={'fix-select ' + side}>
|
|
86
|
+
<select
|
|
87
|
+
aria-label={a['aria-label']}
|
|
88
|
+
value={a.value}
|
|
89
|
+
onChange={a.onChange ? (e) => a.onChange!(e.target.value) : undefined}
|
|
90
|
+
>
|
|
91
|
+
{a.options.map((o) =>
|
|
92
|
+
typeof o === 'string'
|
|
93
|
+
? <option key={o} value={o}>{o}</option>
|
|
94
|
+
: <option key={o.value} value={o.value}>{o.label}</option>
|
|
95
|
+
)}
|
|
96
|
+
</select>
|
|
97
|
+
<svg className="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
98
|
+
<path d="m6 9 6 6 6-6" />
|
|
99
|
+
</svg>
|
|
100
|
+
</span>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return (
|
|
104
|
+
<button
|
|
105
|
+
type="button"
|
|
106
|
+
className={['fix-btn', side, a.primary ? 'is-primary' : ''].filter(Boolean).join(' ')}
|
|
107
|
+
onClick={a.onClick}
|
|
108
|
+
disabled={a.disabled}
|
|
109
|
+
aria-label={a['aria-label']}
|
|
110
|
+
>
|
|
111
|
+
{a.icon}
|
|
112
|
+
{a.label}
|
|
113
|
+
</button>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
49
117
|
export const Input: React.FC<InputProps> = ({
|
|
50
118
|
label,
|
|
51
119
|
help,
|
|
@@ -57,6 +125,9 @@ export const Input: React.FC<InputProps> = ({
|
|
|
57
125
|
trailLabel,
|
|
58
126
|
prefix,
|
|
59
127
|
suffix,
|
|
128
|
+
before,
|
|
129
|
+
after,
|
|
130
|
+
groupShape = 'default',
|
|
60
131
|
onInverse = false,
|
|
61
132
|
readOnlyQuiet = false,
|
|
62
133
|
underline = false,
|
|
@@ -65,7 +136,15 @@ export const Input: React.FC<InputProps> = ({
|
|
|
65
136
|
...rest
|
|
66
137
|
}) => {
|
|
67
138
|
const autoId = React.useId();
|
|
68
|
-
const inputId = id || (label ? autoId : undefined);
|
|
139
|
+
const inputId = id || (label || help != null ? autoId : undefined);
|
|
140
|
+
/* The doc promises that status drives border, help tone AND aria-invalid
|
|
141
|
+
together, and that help is announced with the field. Neither was wired: a
|
|
142
|
+
screen reader heard "Amount, edit text" with no error and no hint. */
|
|
143
|
+
const helpId = help != null ? (inputId || autoId) + '-help' : undefined;
|
|
144
|
+
const a11y = {
|
|
145
|
+
'aria-invalid': status === 'error' ? true : undefined,
|
|
146
|
+
'aria-describedby': [rest['aria-describedby'], helpId].filter(Boolean).join(' ') || undefined,
|
|
147
|
+
} as const;
|
|
69
148
|
|
|
70
149
|
const inputCls = [
|
|
71
150
|
'input',
|
|
@@ -82,13 +161,15 @@ export const Input: React.FC<InputProps> = ({
|
|
|
82
161
|
|
|
83
162
|
let control: React.ReactNode;
|
|
84
163
|
|
|
85
|
-
if (prefix != null || suffix != null) {
|
|
86
|
-
// Attached group — the inner input is styled by `.group > input`.
|
|
164
|
+
if (prefix != null || suffix != null || before != null || after != null) {
|
|
165
|
+
// Attached group — the inner input is styled by `.group > input`. An addon
|
|
166
|
+
// wins over static text on the same side: a cell is either a label or a
|
|
167
|
+
// control, never both.
|
|
87
168
|
control = (
|
|
88
|
-
<div className={['group', status === 'error' ? 'is-error' : ''].filter(Boolean).join(' ')}>
|
|
89
|
-
{prefix != null ? <span className="fix l">{prefix}</span> : null}
|
|
90
|
-
<input id={inputId} readOnly={readOnlyQuiet || rest.readOnly} {...rest} />
|
|
91
|
-
{suffix != null ? <span className="fix r">{suffix}</span> : null}
|
|
169
|
+
<div className={['group', groupShape === 'pill' ? 'is-pill' : '', groupShape === 'underline' ? 'underline' : '', status === 'error' ? 'is-error' : ''].filter(Boolean).join(' ')}>
|
|
170
|
+
{before != null ? renderAddon(before, 'l') : prefix != null ? <span className="fix l">{prefix}</span> : null}
|
|
171
|
+
<input id={inputId} readOnly={readOnlyQuiet || rest.readOnly} {...rest} {...a11y} />
|
|
172
|
+
{after != null ? renderAddon(after, 'r') : suffix != null ? <span className="fix r">{suffix}</span> : null}
|
|
92
173
|
</div>
|
|
93
174
|
);
|
|
94
175
|
} else if (leadIcon != null || trailIcon != null || onTrailClick) {
|
|
@@ -102,7 +183,7 @@ export const Input: React.FC<InputProps> = ({
|
|
|
102
183
|
control = (
|
|
103
184
|
<div className={affixCls}>
|
|
104
185
|
{leadIcon != null ? <span className="lead">{leadIcon}</span> : null}
|
|
105
|
-
<input id={inputId} className={inputCls} readOnly={readOnlyQuiet || rest.readOnly} {...rest} />
|
|
186
|
+
<input id={inputId} className={inputCls} readOnly={readOnlyQuiet || rest.readOnly} {...rest} {...a11y} />
|
|
106
187
|
{onTrailClick ? (
|
|
107
188
|
<button type="button" className="trail-btn" aria-label={trailLabel || 'Action'} onClick={onTrailClick}>
|
|
108
189
|
{trailIcon}
|
|
@@ -113,7 +194,7 @@ export const Input: React.FC<InputProps> = ({
|
|
|
113
194
|
</div>
|
|
114
195
|
);
|
|
115
196
|
} else {
|
|
116
|
-
control = <input id={inputId} className={inputCls} readOnly={readOnlyQuiet || rest.readOnly} {...rest} />;
|
|
197
|
+
control = <input id={inputId} className={inputCls} readOnly={readOnlyQuiet || rest.readOnly} {...rest} {...a11y} />;
|
|
117
198
|
}
|
|
118
199
|
|
|
119
200
|
if (label == null && help == null) return <React.Fragment>{control}</React.Fragment>;
|
|
@@ -131,7 +212,7 @@ export const Input: React.FC<InputProps> = ({
|
|
|
131
212
|
<div className="field">
|
|
132
213
|
{label != null ? <label htmlFor={inputId}>{label}</label> : null}
|
|
133
214
|
{control}
|
|
134
|
-
{help != null ? <div className={helpCls}>{help}</div> : null}
|
|
215
|
+
{help != null ? <div className={helpCls} id={helpId}>{help}</div> : null}
|
|
135
216
|
</div>
|
|
136
217
|
);
|
|
137
218
|
};
|
|
@@ -148,6 +229,7 @@ const fieldWrap = (
|
|
|
148
229
|
status: InputStatus,
|
|
149
230
|
id: string | undefined,
|
|
150
231
|
control: React.ReactNode,
|
|
232
|
+
helpId?: string,
|
|
151
233
|
) => {
|
|
152
234
|
if (label == null && help == null) return <React.Fragment>{control}</React.Fragment>;
|
|
153
235
|
const helpCls = ['help', status === 'error' ? 'is-error' : '', status === 'success' ? 'is-success' : '', status === 'warning' ? 'is-warning' : ''].filter(Boolean).join(' ');
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
+
import type { InputAddon } from '../Input/Input';
|
|
2
3
|
|
|
3
4
|
export interface SearchPillProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
|
|
4
5
|
/** Field height: 'md' (40px, default) or 'sm' (32px). Named fieldSize because
|
|
5
6
|
the native input `size` attribute means character count, not height. */
|
|
6
7
|
fieldSize?: 'sm' | 'md';
|
|
7
8
|
/** Keyboard hint rendered as a .kbd chip (e.g. "⌘K"). */
|
|
8
|
-
shortcut?: string;
|
|
9
|
+
shortcut?: string | string[];
|
|
10
|
+
/** Interactive segment before the search (scope selector). Replaces the icon. */
|
|
11
|
+
before?: InputAddon;
|
|
12
|
+
/** Interactive segment after the search (Search button). Replaces the shortcut. */
|
|
13
|
+
after?: InputAddon;
|
|
9
14
|
/** Leading icon — defaults to the canonical search glyph. */
|
|
10
15
|
icon?: React.ReactNode;
|
|
11
16
|
/** Wrapper (pill) className/style — the input itself fills it. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
+
import type { InputAddon } from '../Input/Input';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* ATTRUS SearchPill — typed wrapper over the canonical `.pill-search`
|
|
@@ -11,8 +12,16 @@ export interface SearchPillProps extends Omit<React.InputHTMLAttributes<HTMLInpu
|
|
|
11
12
|
the native input `size` attribute means character count, not height. Use
|
|
12
13
|
'sm' inside a toolbar, where a 40px field towers over 32px buttons. */
|
|
13
14
|
fieldSize?: 'sm' | 'md';
|
|
14
|
-
/** Keyboard hint rendered as a .kbd chip
|
|
15
|
-
|
|
15
|
+
/** Keyboard hint rendered as a .kbd chip. A string with a modifier glyph is
|
|
16
|
+
split into one cell per key ("⌘K" → ⌘ · K) so symbol and letter share a
|
|
17
|
+
baseline. Pass an array for multi-letter keys: ['Ctrl', 'K']. */
|
|
18
|
+
shortcut?: string | string[];
|
|
19
|
+
/** Interactive segment before the search — e.g. a scope selector ("All ▾").
|
|
20
|
+
Replaces the leading icon: a pill has room for one lead. */
|
|
21
|
+
before?: InputAddon;
|
|
22
|
+
/** Interactive segment after the search — e.g. a "Search" button. Replaces
|
|
23
|
+
the shortcut hint: a field that shows a button does not need a hint. */
|
|
24
|
+
after?: InputAddon;
|
|
16
25
|
/** Leading icon — defaults to the canonical search glyph. */
|
|
17
26
|
icon?: React.ReactNode;
|
|
18
27
|
/** Wrapper (pill) className/style — the input itself fills it. */
|
|
@@ -27,18 +36,59 @@ const SearchGlyph: React.FC = () => (
|
|
|
27
36
|
</svg>
|
|
28
37
|
);
|
|
29
38
|
|
|
39
|
+
const MODIFIERS = /^[⌘⌥⇧⌃]/;
|
|
40
|
+
function keys(s: string | string[]): string[] {
|
|
41
|
+
if (Array.isArray(s)) return s;
|
|
42
|
+
// "⌘K" → ["⌘","K"]; "⇧⌘P" → ["⇧","⌘","P"]; a plain word stays one key
|
|
43
|
+
const out: string[] = [];
|
|
44
|
+
let rest = s;
|
|
45
|
+
while (MODIFIERS.test(rest)) { out.push(rest[0]); rest = rest.slice(1); }
|
|
46
|
+
if (rest) out.push(rest);
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function renderAddon(a: InputAddon, side: 'l' | 'r'): React.ReactNode {
|
|
51
|
+
if (a.type === 'select') {
|
|
52
|
+
return (
|
|
53
|
+
<span className={'fix-select ' + side}>
|
|
54
|
+
<select aria-label={a['aria-label']} value={a.value} onChange={a.onChange ? (e) => a.onChange!(e.target.value) : undefined}>
|
|
55
|
+
{a.options.map((o) =>
|
|
56
|
+
typeof o === 'string' ? <option key={o} value={o}>{o}</option> : <option key={o.value} value={o.value}>{o.label}</option>
|
|
57
|
+
)}
|
|
58
|
+
</select>
|
|
59
|
+
<svg className="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
60
|
+
<path d="m6 9 6 6 6-6" />
|
|
61
|
+
</svg>
|
|
62
|
+
</span>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return (
|
|
66
|
+
<button type="button" className={['fix-btn', side, a.primary ? 'is-primary' : ''].filter(Boolean).join(' ')}
|
|
67
|
+
onClick={a.onClick} disabled={a.disabled} aria-label={a['aria-label']}>
|
|
68
|
+
{a.icon}
|
|
69
|
+
{a.label}
|
|
70
|
+
</button>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
30
74
|
export const SearchPill: React.FC<SearchPillProps> = ({
|
|
31
75
|
shortcut,
|
|
32
76
|
icon,
|
|
33
77
|
fieldSize = 'md',
|
|
78
|
+
before,
|
|
79
|
+
after,
|
|
34
80
|
wrapperClassName,
|
|
35
81
|
wrapperStyle,
|
|
36
82
|
...rest
|
|
37
83
|
}) => (
|
|
38
84
|
<div className={['pill-search', fieldSize === 'sm' ? 'pill-search-sm' : '', wrapperClassName || ''].filter(Boolean).join(' ')} style={wrapperStyle}>
|
|
39
|
-
{icon != null ? icon : <SearchGlyph />}
|
|
85
|
+
{before != null ? renderAddon(before, 'l') : icon != null ? icon : <SearchGlyph />}
|
|
40
86
|
<input type="search" {...rest} />
|
|
41
|
-
{
|
|
87
|
+
{after != null
|
|
88
|
+
? renderAddon(after, 'r')
|
|
89
|
+
: shortcut
|
|
90
|
+
? <span className="kbd">{keys(shortcut).map((k) => <kbd key={k}>{k}</kbd>)}</span>
|
|
91
|
+
: null}
|
|
42
92
|
</div>
|
|
43
93
|
);
|
|
44
94
|
|
package/package.json
CHANGED