@attrus-ds/core 1.0.17 → 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.
@@ -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
- .callout-bar > i, .callout-bar > svg, .callout-bar .callout-icon { margin-top: 0; }
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
- box-shadow: var(--shadow-xs);
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
- box-shadow: var(--shadow-xs);
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: 1px solid var(--color-border-default);
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
- box-shadow: var(--shadow-sm);
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
- box-shadow: var(--shadow-xs);
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
- /* Box on the element, not on a descendant selector: the height must survive a
67
- toolbar that is not a direct child of .dt-wrap, or the selecting state
68
- renders 5px taller and the table jumps when the first row is ticked. */
69
- height: 52px;
70
- padding: 0 var(--space-5);
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
- box-shadow: var(--shadow-xs);
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: 52px;
1207
- padding: 0 var(--space-5);
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
- .dt-toolbar.dt-toolbar-sm { height: 44px; padding: 0 var(--space-4); }
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 — the wrap only draws a border when nothing else bounds it
1274
+ FRAME OWNERSHIP
1267
1275
  ----------------------------------------------------------------
1268
- .dt-wrap's border says "the table starts and ends here". A toolbar closes the
1269
- top, a pager closes the bottom, and a card/drawer closes all four — in those
1270
- cases the border is a second contour around a boundary that already reads.
1271
- Radius stays either way: it is what clips the thead corners.
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 (e.g. "⌘K"). */
15
- shortcut?: string;
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
- {shortcut ? <span className="kbd">{shortcut}</span> : null}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@attrus-ds/core",
3
- "version": "1.0.17",
3
+ "version": "1.0.19",
4
4
  "description": "ATTRUS / Cabiros design system — design tokens, component styles, brand assets, fonts and an optional typed React component layer.",
5
5
  "keywords": [
6
6
  "design-system",