@attrus-ds/core 1.0.18 → 1.0.20

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,73 @@
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
+ /* No weight change: the fill already marks the selection, and a bolder label
365
+ widens the segment on toggle, nudging every neighbour sideways. */
366
+ /* The overlay stops at the border, and the next segment overlaps this one by a
367
+ hairline — so without this the selected segment ended in a 1px light strip
368
+ (its own untinted border, covered by the neighbour's fill). Lifting it above
369
+ its neighbours and drawing its border in the seam colour closes the edge:
370
+ the selected segment reads as one tinted block with a clean outline. */
371
+ position: relative;
372
+ z-index: 1;
373
+ border-color: color-mix(in srgb, currentColor 22%, transparent);
374
+ }
375
+ /* Borderless variants paint the overlay as a background instead. Their 1px
376
+ border is TRANSPARENT, and an inset shadow stops at the border — so it left a
377
+ 1px untinted ring around the selected segment, most visible on its right edge.
378
+ With no hairline to thicken, tinting the border box is exactly right here. */
379
+ .btn-group .btn-primary[aria-pressed="true"],
380
+ .btn-group .btn-tertiary[aria-pressed="true"],
381
+ .btn-group .btn-ghost[aria-pressed="true"],
382
+ .btn-group .btn-danger[aria-pressed="true"],
383
+ .btn-group .btn-inverse[aria-pressed="true"] {
384
+ background-image: linear-gradient(color-mix(in srgb, currentColor 14%, transparent), color-mix(in srgb, currentColor 14%, transparent));
385
+ /* borderless: keep the border transparent — the fill already covers it */
386
+ border-color: transparent;
387
+ }
388
+ /* The tinted block is its own divider on borderless variants, so neither it nor
389
+ the segment after it draws a seam. A seam there sits one pixel inside the
390
+ neighbour's transparent border and leaves a light pixel between the tint and
391
+ the line — a doubled edge on both sides of the selected segment. */
392
+ .btn-group .btn-primary[aria-pressed="true"],
393
+ .btn-group .btn-tertiary[aria-pressed="true"],
394
+ .btn-group .btn-ghost[aria-pressed="true"],
395
+ .btn-group .btn-danger[aria-pressed="true"],
396
+ .btn-group .btn-inverse[aria-pressed="true"],
397
+ .btn-group .btn-primary[aria-pressed="true"] + .btn-primary,
398
+ .btn-group .btn-tertiary[aria-pressed="true"] + .btn-tertiary,
399
+ .btn-group .btn-ghost[aria-pressed="true"] + .btn-ghost,
400
+ .btn-group .btn-danger[aria-pressed="true"] + .btn-danger,
401
+ .btn-group .btn-inverse[aria-pressed="true"] + .btn-inverse { box-shadow: none; }
402
+
@@ -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
@@ -1090,3 +1302,121 @@ textarea.input.input-lg { height: auto; min-height: 120px; padding: var(--space-
1090
1302
  background: color-mix(in srgb, var(--color-pure-white) 6%, transparent);
1091
1303
  border-color: var(--color-border-inverse);
1092
1304
  }
1305
+
1306
+ /* ----------------------------------------------------------------
1307
+ INSET ACTION BUTTON — .fix-btn reads as a button, not a label
1308
+ ----------------------------------------------------------------
1309
+ A flush segment (divider + field fill) is the right language for a
1310
+ SELECTOR: it qualifies the value, so it belongs to the field's surface, and
1311
+ the chevron already says "dropdown". But the same treatment on an ACTION
1312
+ made it read as a static label glued to the field. An action is its own
1313
+ control, so it sits INSIDE the field as a button: inset from the edge,
1314
+ its own border and radius, its own fill. .fix-select is untouched.
1315
+ The inset is 6px on every side; the button's radius is the field's minus
1316
+ that inset, so the two curves stay concentric.
1317
+ ---------------------------------------------------------------- */
1318
+ :is(.group, .pill-search) > .fix-btn {
1319
+ --fix-inset: 6px;
1320
+ align-self: stretch;
1321
+ height: auto;
1322
+ margin: var(--fix-inset);
1323
+ padding: 0 var(--space-3);
1324
+ border: var(--border-hairline) solid var(--color-border-default);
1325
+ /* Concentric (field radius − inset) comes out ~2px on a 6px field, which
1326
+ reads as a square chip. Floor it at --radius-xs so it still reads as a
1327
+ button; the curves stay visually parallel at this size. */
1328
+ border-radius: max(var(--radius-xs), calc(var(--shape-control) - var(--fix-inset)));
1329
+ background: var(--color-background-surface);
1330
+ color: var(--color-foreground-default);
1331
+ box-shadow: none;
1332
+ }
1333
+ /* the flush-segment dividers do not apply to a button that has its own border */
1334
+ :is(.group, .pill-search) > .fix-btn.l,
1335
+ :is(.group, .pill-search) > .fix-btn.r { border: var(--border-hairline) solid var(--color-border-default); }
1336
+ :is(.group, .pill-search) > .fix-btn.l { margin-right: 0; }
1337
+ :is(.group, .pill-search) > .fix-btn.r { margin-left: 0; }
1338
+ :is(.group, .pill-search) > .fix-btn:hover { background: var(--color-background-hover); border-color: var(--color-border-strong); color: var(--color-foreground-default); }
1339
+ :is(.group, .pill-search) > .fix-btn:active { background: var(--color-background-press); }
1340
+ :is(.group, .pill-search) > .fix-btn:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--color-border-focus); }
1341
+ :is(.group, .pill-search) > .fix-btn.is-primary {
1342
+ background: var(--color-brand-solid);
1343
+ border-color: transparent;
1344
+ color: var(--color-foreground-on-solid);
1345
+ }
1346
+ :is(.group, .pill-search) > .fix-btn.is-primary:hover { background: var(--color-brand-solid-hover); border-color: transparent; color: var(--color-foreground-on-solid); }
1347
+ :is(.group, .pill-search) > .fix-btn.is-primary:focus-visible { box-shadow: 0 0 0 2px var(--color-border-focus); }
1348
+
1349
+ /* Pill field → pill button (concentric), and the end-padding the flush
1350
+ segment needed to clear the round cap is no longer needed */
1351
+ :is(.group.is-pill, .pill-search) > .fix-btn,
1352
+ :is(.group.is-pill, .pill-search) > .fix-btn:first-child,
1353
+ :is(.group.is-pill, .pill-search) > .fix-btn:last-child { border-radius: var(--radius-pill); padding: 0 var(--space-4); }
1354
+ /* the pill clips its children for the flush selector; a clipped focus ring
1355
+ on the inset button would be cut, so the ring is drawn inside instead */
1356
+ .pill-search > .fix-btn:focus-visible,
1357
+ .pill-search > .fix-btn.is-primary:focus-visible { box-shadow: inset 0 0 0 2px var(--color-border-focus); }
1358
+ .pill-search > .fix-btn.is-primary:focus-visible { box-shadow: inset 0 0 0 2px var(--color-foreground-on-solid), 0 0 0 0 transparent; }
1359
+
1360
+ /* Underline field → a compact bordered button on the rule, not floating text */
1361
+ .group.underline > .fix-btn {
1362
+ align-self: center;
1363
+ height: calc(100% - 12px);
1364
+ margin: 0 0 0 var(--space-2);
1365
+ padding: 0 var(--space-3);
1366
+ border: var(--border-hairline) solid var(--color-border-default);
1367
+ border-radius: var(--shape-control);
1368
+ background: var(--color-background-surface);
1369
+ color: var(--color-foreground-default);
1370
+ font-weight: var(--w-regular);
1371
+ text-decoration: none;
1372
+ }
1373
+ .group.underline > .fix-btn:first-child { margin: 0 var(--space-2) 0 0; }
1374
+ .group.underline > .fix-btn:last-child { margin-right: 0; }
1375
+ .group.underline > .fix-btn.is-primary { background: var(--color-brand-solid); border-color: transparent; color: var(--color-foreground-on-solid); font-weight: var(--w-regular); }
1376
+ .group.underline > .fix-btn.is-primary:hover { background: var(--color-brand-solid-hover); color: var(--color-foreground-on-solid); }
1377
+ .group.underline > .fix-btn:focus-visible,
1378
+ .group.underline > .fix-btn.is-primary:focus-visible { box-shadow: 0 0 0 2px var(--color-border-focus); text-decoration: none; }
1379
+
1380
+ /* on-inverse keeps the same shape, tinted for dark surfaces */
1381
+ .group.on-inverse > .fix-btn:not(.is-primary),
1382
+ .pill-search.on-inverse > .fix-btn:not(.is-primary) {
1383
+ background: color-mix(in srgb, var(--color-pure-white) 8%, transparent);
1384
+ border-color: var(--color-border-inverse);
1385
+ color: var(--color-foreground-inverse-default);
1386
+ }
1387
+
1388
+ /* ----------------------------------------------------------------
1389
+ ADDON VARIANTS — the inset button speaks the Button vocabulary
1390
+ ----------------------------------------------------------------
1391
+ .fix-btn takes the same emphasis ladder as .btn, with the same tokens, so
1392
+ "Apply" inside a field and "Apply" beside it read identically:
1393
+ (none) / .is-secondary neutral surface + hairline border (default)
1394
+ .is-primary brand fill — THE action of this field
1395
+ .is-tertiary soft neutral fill, borderless — quiet affirmative
1396
+ .is-ghost nothing until hover — secondary tools (copy, clear)
1397
+ .is-danger destructive solid
1398
+ .is-danger-outline destructive, contextual
1399
+ .is-success strong positive (rare)
1400
+ Not offered: .link (it would read as a label again — the problem this button
1401
+ exists to solve) and .inverse (the field's own on-inverse handles dark).
1402
+ ---------------------------------------------------------------- */
1403
+ :is(.group, .pill-search) > .fix-btn.is-tertiary {
1404
+ background: color-mix(in srgb, var(--color-foreground-default) 7%, transparent);
1405
+ border-color: transparent;
1406
+ color: var(--color-foreground-default);
1407
+ }
1408
+ :is(.group, .pill-search) > .fix-btn.is-tertiary:hover { background: color-mix(in srgb, var(--color-foreground-default) 12%, transparent); border-color: transparent; }
1409
+ :is(.group, .pill-search) > .fix-btn.is-ghost { background: transparent; border-color: transparent; color: var(--color-foreground-secondary); }
1410
+ :is(.group, .pill-search) > .fix-btn.is-ghost:hover { background: var(--color-background-hover); border-color: transparent; color: var(--color-foreground-default); }
1411
+ :is(.group, .pill-search) > .fix-btn.is-danger { background: var(--color-danger-500); border-color: transparent; color: var(--color-foreground-on-solid); }
1412
+ :is(.group, .pill-search) > .fix-btn.is-danger:hover { background: var(--color-danger-700); border-color: transparent; color: var(--color-foreground-on-solid); }
1413
+ :is(.group, .pill-search) > .fix-btn.is-danger-outline { background: var(--color-background-surface); border-color: var(--color-feedback-danger-border); color: var(--color-danger-500); }
1414
+ :is(.group, .pill-search) > .fix-btn.is-danger-outline:hover { background: var(--color-feedback-danger-background); border-color: var(--color-danger-500); color: var(--color-danger-500); }
1415
+ :is(.group, .pill-search) > .fix-btn.is-success { background: var(--color-success-500); border-color: transparent; color: var(--color-foreground-on-solid); }
1416
+ :is(.group, .pill-search) > .fix-btn.is-success:hover { background: var(--color-success-700); border-color: transparent; color: var(--color-foreground-on-solid); }
1417
+ /* the underline rules above set a surface fill; restate the variants there */
1418
+ .group.underline > .fix-btn.is-tertiary { background: color-mix(in srgb, var(--color-foreground-default) 7%, transparent); border-color: transparent; }
1419
+ .group.underline > .fix-btn.is-ghost { background: transparent; border-color: transparent; color: var(--color-foreground-secondary); }
1420
+ .group.underline > .fix-btn.is-danger { background: var(--color-danger-500); border-color: transparent; color: var(--color-foreground-on-solid); }
1421
+ .group.underline > .fix-btn.is-danger-outline { background: var(--color-background-surface); border-color: var(--color-feedback-danger-border); color: var(--color-danger-500); }
1422
+ .group.underline > .fix-btn.is-success { background: var(--color-success-500); border-color: transparent; color: var(--color-foreground-on-solid); }
@@ -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,16 @@ 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 InputAddonVariant =
7
+ | 'primary' | 'secondary' | 'tertiary' | 'ghost'
8
+ | 'danger' | 'danger-outline' | 'success';
9
+
10
+ export type InputAddon =
11
+ | { type: 'select'; options: Array<string | { value: string; label: React.ReactNode }>; value?: string; onChange?: (value: string) => void; 'aria-label': string }
12
+ | { type: 'button'; label: React.ReactNode; icon?: React.ReactNode; onClick?: () => void; variant?: InputAddonVariant;
13
+ /** @deprecated use variant: 'primary' */
14
+ primary?: boolean; disabled?: boolean; 'aria-label'?: string };
15
+
6
16
  export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size' | 'prefix'> {
7
17
  /** Field label (renders the .field wrapper). */
8
18
  label?: React.ReactNode;
@@ -24,6 +34,12 @@ export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElem
24
34
  prefix?: React.ReactNode;
25
35
  /** Attached suffix cell — .group .fix.r (e.g. ".00"). */
26
36
  suffix?: React.ReactNode;
37
+ /** Interactive segment before the field — selector or button (combo input). */
38
+ before?: InputAddon;
39
+ /** Interactive segment after the field — selector or button (combo input). */
40
+ after?: InputAddon;
41
+ /** Shape of an attached group: rectangle, rounded ends, or bottom rule only. */
42
+ groupShape?: 'default' | 'pill' | 'underline';
27
43
  /** Dark-surface treatment (.on-inverse). */
28
44
  onInverse?: boolean;
29
45
  /** 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,40 @@ 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
+ /** Button emphasis available to an addon. Same names and tokens as Button;
26
+ 'link' and 'inverse' are left out — a link inside a field reads as a label,
27
+ and dark surfaces are the field's own on-inverse job. */
28
+ export type InputAddonVariant =
29
+ | 'primary' | 'secondary' | 'tertiary' | 'ghost'
30
+ | 'danger' | 'danger-outline' | 'success';
31
+
32
+ export type InputAddon =
33
+ | {
34
+ type: 'select';
35
+ options: Array<string | { value: string; label: React.ReactNode }>;
36
+ value?: string;
37
+ onChange?: (value: string) => void;
38
+ /** Required: the select has no visible label of its own. */
39
+ 'aria-label': string;
40
+ }
41
+ | {
42
+ type: 'button';
43
+ label: React.ReactNode;
44
+ icon?: React.ReactNode;
45
+ onClick?: () => void;
46
+ /** Emphasis — the Button ladder, minus link/inverse. Default 'secondary'.
47
+ 'primary' only when this is THE action of the field. */
48
+ variant?: InputAddonVariant;
49
+ /** @deprecated use variant: 'primary' */
50
+ primary?: boolean;
51
+ disabled?: boolean;
52
+ 'aria-label'?: string;
53
+ };
54
+
20
55
  export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size' | 'prefix'> {
21
56
  /** Field label (renders the .field wrapper). */
22
57
  label?: React.ReactNode;
@@ -38,6 +73,14 @@ export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElem
38
73
  prefix?: React.ReactNode;
39
74
  /** Attached suffix cell — .group .fix.r (e.g. ".00"). */
40
75
  suffix?: React.ReactNode;
76
+ /** Interactive segment BEFORE the field — selector or button (combo input).
77
+ Takes the slot prefix would use; pass one or the other, not both. */
78
+ before?: InputAddon;
79
+ /** Interactive segment AFTER the field — selector or button (combo input). */
80
+ after?: InputAddon;
81
+ /** Shape of an attached group (prefix/suffix/before/after): the rectangular
82
+ field, rounded ends (.group.is-pill) or bottom-rule only (.group.underline). */
83
+ groupShape?: 'default' | 'pill' | 'underline';
41
84
  /** Dark-surface treatment (.on-inverse). */
42
85
  onInverse?: boolean;
43
86
  /** Quiet read-only: value only, no border (.is-readonly). */
@@ -46,6 +89,41 @@ export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElem
46
89
  underline?: boolean;
47
90
  }
48
91
 
92
+ function renderAddon(a: InputAddon, side: 'l' | 'r'): React.ReactNode {
93
+ if (a.type === 'select') {
94
+ return (
95
+ <span className={'fix-select ' + side}>
96
+ <select
97
+ aria-label={a['aria-label']}
98
+ value={a.value}
99
+ onChange={a.onChange ? (e) => a.onChange!(e.target.value) : undefined}
100
+ >
101
+ {a.options.map((o) =>
102
+ typeof o === 'string'
103
+ ? <option key={o} value={o}>{o}</option>
104
+ : <option key={o.value} value={o.value}>{o.label}</option>
105
+ )}
106
+ </select>
107
+ <svg className="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
108
+ <path d="m6 9 6 6 6-6" />
109
+ </svg>
110
+ </span>
111
+ );
112
+ }
113
+ return (
114
+ <button
115
+ type="button"
116
+ className={['fix-btn', side, (() => { const v = a.variant || (a.primary ? 'primary' : 'secondary'); return v === 'secondary' ? '' : 'is-' + v; })()].filter(Boolean).join(' ')}
117
+ onClick={a.onClick}
118
+ disabled={a.disabled}
119
+ aria-label={a['aria-label']}
120
+ >
121
+ {a.icon}
122
+ {a.label}
123
+ </button>
124
+ );
125
+ }
126
+
49
127
  export const Input: React.FC<InputProps> = ({
50
128
  label,
51
129
  help,
@@ -57,6 +135,9 @@ export const Input: React.FC<InputProps> = ({
57
135
  trailLabel,
58
136
  prefix,
59
137
  suffix,
138
+ before,
139
+ after,
140
+ groupShape = 'default',
60
141
  onInverse = false,
61
142
  readOnlyQuiet = false,
62
143
  underline = false,
@@ -65,7 +146,15 @@ export const Input: React.FC<InputProps> = ({
65
146
  ...rest
66
147
  }) => {
67
148
  const autoId = React.useId();
68
- const inputId = id || (label ? autoId : undefined);
149
+ const inputId = id || (label || help != null ? autoId : undefined);
150
+ /* The doc promises that status drives border, help tone AND aria-invalid
151
+ together, and that help is announced with the field. Neither was wired: a
152
+ screen reader heard "Amount, edit text" with no error and no hint. */
153
+ const helpId = help != null ? (inputId || autoId) + '-help' : undefined;
154
+ const a11y = {
155
+ 'aria-invalid': status === 'error' ? true : undefined,
156
+ 'aria-describedby': [rest['aria-describedby'], helpId].filter(Boolean).join(' ') || undefined,
157
+ } as const;
69
158
 
70
159
  const inputCls = [
71
160
  'input',
@@ -82,13 +171,15 @@ export const Input: React.FC<InputProps> = ({
82
171
 
83
172
  let control: React.ReactNode;
84
173
 
85
- if (prefix != null || suffix != null) {
86
- // Attached group — the inner input is styled by `.group > input`.
174
+ if (prefix != null || suffix != null || before != null || after != null) {
175
+ // Attached group — the inner input is styled by `.group > input`. An addon
176
+ // wins over static text on the same side: a cell is either a label or a
177
+ // control, never both.
87
178
  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}
179
+ <div className={['group', groupShape === 'pill' ? 'is-pill' : '', groupShape === 'underline' ? 'underline' : '', status === 'error' ? 'is-error' : ''].filter(Boolean).join(' ')}>
180
+ {before != null ? renderAddon(before, 'l') : prefix != null ? <span className="fix l">{prefix}</span> : null}
181
+ <input id={inputId} readOnly={readOnlyQuiet || rest.readOnly} {...rest} {...a11y} />
182
+ {after != null ? renderAddon(after, 'r') : suffix != null ? <span className="fix r">{suffix}</span> : null}
92
183
  </div>
93
184
  );
94
185
  } else if (leadIcon != null || trailIcon != null || onTrailClick) {
@@ -102,7 +193,7 @@ export const Input: React.FC<InputProps> = ({
102
193
  control = (
103
194
  <div className={affixCls}>
104
195
  {leadIcon != null ? <span className="lead">{leadIcon}</span> : null}
105
- <input id={inputId} className={inputCls} readOnly={readOnlyQuiet || rest.readOnly} {...rest} />
196
+ <input id={inputId} className={inputCls} readOnly={readOnlyQuiet || rest.readOnly} {...rest} {...a11y} />
106
197
  {onTrailClick ? (
107
198
  <button type="button" className="trail-btn" aria-label={trailLabel || 'Action'} onClick={onTrailClick}>
108
199
  {trailIcon}
@@ -113,7 +204,7 @@ export const Input: React.FC<InputProps> = ({
113
204
  </div>
114
205
  );
115
206
  } else {
116
- control = <input id={inputId} className={inputCls} readOnly={readOnlyQuiet || rest.readOnly} {...rest} />;
207
+ control = <input id={inputId} className={inputCls} readOnly={readOnlyQuiet || rest.readOnly} {...rest} {...a11y} />;
117
208
  }
118
209
 
119
210
  if (label == null && help == null) return <React.Fragment>{control}</React.Fragment>;
@@ -131,7 +222,7 @@ export const Input: React.FC<InputProps> = ({
131
222
  <div className="field">
132
223
  {label != null ? <label htmlFor={inputId}>{label}</label> : null}
133
224
  {control}
134
- {help != null ? <div className={helpCls}>{help}</div> : null}
225
+ {help != null ? <div className={helpCls} id={helpId}>{help}</div> : null}
135
226
  </div>
136
227
  );
137
228
  };
@@ -148,6 +239,7 @@ const fieldWrap = (
148
239
  status: InputStatus,
149
240
  id: string | undefined,
150
241
  control: React.ReactNode,
242
+ helpId?: string,
151
243
  ) => {
152
244
  if (label == null && help == null) return <React.Fragment>{control}</React.Fragment>;
153
245
  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, (() => { const v = a.variant || (a.primary ? 'primary' : 'secondary'); return v === 'secondary' ? '' : 'is-' + v; })()].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.18",
3
+ "version": "1.0.20",
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",