@attrus-ds/core 1.0.13 → 1.0.14

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.
@@ -98,6 +98,7 @@
98
98
  gap: 0;
99
99
  }
100
100
  .tabs-pill .tab {
101
+ transition: background var(--transition-fast), color var(--transition-fast), box-shadow var(--transition-fast);
101
102
  padding: var(--space-2) var(--space-4);
102
103
  border-radius: var(--radius-pill);
103
104
  margin-bottom: 0;
@@ -15,7 +15,7 @@
15
15
  display: inline-flex;
16
16
  align-items: center;
17
17
  gap: var(--space-2);
18
- height: 32px;
18
+ height: var(--control-h-sm);
19
19
  padding: 0 var(--space-4);
20
20
  background: var(--color-background-surface);
21
21
  border: 1px solid var(--color-border-default);
@@ -94,7 +94,7 @@
94
94
 
95
95
  /* ---------- Dense ---------- */
96
96
  .toggle-chip-sm {
97
- height: 26px;
97
+ height: var(--control-h-xs);
98
98
  padding: 0 var(--space-3);
99
99
  font-size: var(--fs-xs);
100
100
  }
@@ -5,26 +5,42 @@
5
5
  (preview/topbar.html). Token-driven → themes automatically.
6
6
 
7
7
  Class map:
8
- .topbar component root (52px bar, surface)
9
- .tb-control unified 32px control (button/pill base)
10
- .tb-control.icon icon-only square 32×32
11
- .tb-control.primary high-contrast CTA (inverse layer)
8
+ .topbar component root (60px bar, surface)
9
+ .topbar.is-sm/.is-lg bar density — retunes --tb-control-h (52 / 72px)
10
+ .topbar.is-compact narrow layout at any viewport (see Compact below)
11
+ .topbar.is-glass frosted chrome via --glass-chrome
12
+ .topbar.is-glass.is-sticky pinned, which is what makes the blur mean anything
13
+ .tb-control unified control, height = --tb-control-h
14
+ .tb-control.icon icon-only square
15
+ .tb-control.sm/.lg one-off control size (leaves bar height alone)
12
16
  .tb-live live-session indicator (non-interactive)
13
- .tb-live .dot pulsing status dot
17
+ .tb-live .dot status dot
14
18
  .tb-clock mono elapsed-time readout
15
19
  .tb-flag-label ENG / lang label inside a control
20
+ .tb-lang language control — tagged so compact can hide it
16
21
  .tb-bell notification button wrapper (badge anchor)
17
22
  .tb-bell .badge unread red dot
18
- .tb-avatar user avatar chip (30px)
23
+ .tb-avatar user avatar chip (32px)
24
+ .tb-mark-full brand lockup (hidden when compact)
25
+ .tb-mark-symbol brand glyph (hidden when compact)
19
26
  .tb-spacer margin-left:auto group wrapper
27
+ .tb-account avatar + identity trigger
28
+ .tb-account-id name / company stack inside the trigger
29
+ .tb-account-menu dropdown (head, -sep, -item, -item.is-danger)
20
30
 
21
- Canonical control height: var(--tb-control-h) (default 36px). All bar
22
- items (controls, search, account) read it, so they scale together.
31
+ The primary action is NOT a topbar class: use the canonical .btn .btn-primary
32
+ .btn-md (buttons.css) so the bar's CTA stays the system's button.
33
+
34
+ Canonical control height: var(--tb-control-h), which carries 40px as its own
35
+ fallback — a control used outside a .topbar still lands on the right box. All bar items
36
+ (controls, search, account) read it, so they scale together. 40px matches the
37
+ standard 40px input/button box used elsewhere, so a .tb-control and a .btn-md
38
+ in the same row align without per-instance tuning.
23
39
  ============================================================ */
24
40
 
25
41
  .topbar {
26
42
  height: 60px; flex-shrink: 0;
27
- --tb-control-h: 36px;
43
+ --tb-control-h: var(--control-h-md);
28
44
  background: var(--color-background-surface);
29
45
  border-bottom: 1px solid var(--color-border-default);
30
46
  display: flex; align-items: center; gap: var(--space-2-5);
@@ -33,7 +49,7 @@
33
49
 
34
50
  /* Unified control — every interactive pill/button in the bar */
35
51
  .tb-control {
36
- height: var(--tb-control-h); min-height: var(--tb-control-h); box-sizing: border-box;
52
+ height: var(--tb-control-h, var(--control-h-md)); min-height: var(--tb-control-h, var(--control-h-md)); box-sizing: border-box;
37
53
  display: inline-flex; align-items: center; gap: var(--space-2-5);
38
54
  padding: 0 var(--space-3-5);
39
55
  border-radius: var(--radius-sm-md);
@@ -48,17 +64,28 @@
48
64
  .tb-control svg, .tb-control i { width: 14px; height: 14px; stroke-width: var(--icon-stroke); flex-shrink: 0; }
49
65
 
50
66
  /* Icon-only square */
51
- .tb-control.icon { width: var(--tb-control-h); padding: 0; justify-content: center; }
67
+ .tb-control.icon { width: var(--tb-control-h, var(--control-h-md)); padding: 0; justify-content: center; }
52
68
 
53
- /* The bar's primary action is the canonical button — use `.btn .btn-primary .btn-sm`
54
- (buttons.css), NOT a topbar-local style. Kept out of here on purpose for single source. */
69
+ /* The bar's primary action is the canonical .btn (buttons.css) — no topbar-local
70
+ button style. The bar does impose its own box on it, so the CTA tracks the
71
+ density instead of depending on the author picking the matching .btn-* class:
72
+ both now land on the same step of the height scale. */
73
+ .topbar .btn { height: var(--tb-control-h, var(--control-h-md)); }
55
74
 
56
75
  /* Lang label inside a control */
57
76
  .tb-control .tb-flag-label { font-size: var(--fs-sm); font-weight: var(--w-regular); color: var(--color-foreground-secondary); }
58
77
  .tb-control .chev { opacity: .6; }
59
78
  .tb-control .chev svg, .tb-control .chev i { width: 11px; height: 11px; }
60
79
 
61
- /* Live indicator — looks like a control but is not interactive */
80
+ /* Live indicator — looks like a control but is not interactive.
81
+ Baseline, not centre: the clock is mono --fs-xxs next to a sans --fs-sm label,
82
+ Alignment note: do NOT reach for align-items:baseline here. In a fixed-height
83
+ flex box it aligns the items to each other but glues the run to the top (10px
84
+ off centre in a 40px pill). The clock reading "higher" is a LEADING problem:
85
+ label is --fs-sm (18px box) beside a --fs-xxs clock (15px box), so centring
86
+ boxes of different heights centres the leading, not the glyphs. line-height:1
87
+ on both makes each box hug its letterforms, and the default centring lines
88
+ them up. */
62
89
  .tb-live { cursor: default; color: var(--color-foreground-secondary); }
63
90
  .tb-live:hover { border-color: var(--color-border-default); background: var(--color-background-surface); color: var(--color-foreground-secondary); }
64
91
  .tb-live .dot {
@@ -66,9 +93,10 @@
66
93
  background: var(--color-success-500);
67
94
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-success-500) 15%, transparent);
68
95
  }
69
- .tb-live .lbl { font-size: var(--fs-sm); font-weight: var(--w-regular); }
96
+ .tb-live .lbl { font-size: var(--fs-sm); font-weight: var(--w-regular); line-height: 1; }
70
97
  .tb-clock { font-family: var(--font-mono);
71
- font-size-adjust: var(--font-mono-adjust); font-size: var(--fs-xxs); color: var(--color-foreground-muted); margin-left: var(--space-1); }
98
+ font-size-adjust: var(--font-mono-adjust); font-size: var(--fs-xxs); line-height: 1;
99
+ color: var(--color-foreground-muted); margin-left: var(--space-1); }
72
100
 
73
101
  /* Notification button + unread badge */
74
102
  .tb-bell .badge-anchor { position: relative; display: inline-flex; }
@@ -80,7 +108,8 @@
80
108
 
81
109
  /* Avatar chip */
82
110
  .tb-avatar {
83
- width: 30px; height: 30px; border-radius: var(--radius-pill);
111
+ /* One step below the control — the relation it already had at md (40→32) */
112
+ width: var(--control-h-sm); height: var(--control-h-sm); border-radius: var(--radius-pill);
84
113
  background: var(--color-brand-solid); color: var(--color-brand-on-solid);
85
114
  font-size: var(--fs-xs); font-weight: var(--w-bold);
86
115
  display: inline-flex; align-items: center; justify-content: center;
@@ -91,17 +120,72 @@
91
120
  .tb-spacer { margin-left: auto; display: flex; align-items: center; gap: var(--space-3); }
92
121
 
93
122
  /* Search pill grows to fill the bar (component itself lives in inputs.css) */
94
- .topbar .pill-search { height: var(--tb-control-h); box-sizing: border-box; flex: 1; max-width: 520px; min-width: 0; }
123
+ .topbar .pill-search { height: var(--tb-control-h, var(--control-h-md)); box-sizing: border-box; flex: 1; max-width: 520px; min-width: 0; }
124
+
125
+ /* ===== Compact / mobile =====
126
+ The collapse is driven by a MODIFIER, not only by the breakpoint. A media
127
+ query reads the viewport, so a bar rendered inside a narrow container — a doc
128
+ demo, a split pane, a drawer — would keep its desktop layout while visibly
129
+ lacking the room for it. `.is-compact` lets the consumer say "this bar is
130
+ narrow" directly, and the breakpoint simply opts every bar in below 720px.
95
131
 
96
- /* ===== Mobile ===== */
132
+ Two collapses, both preserving the control box so no tap target shrinks:
133
+ 1. brand off. Both marks hide: the menu button and the account already say
134
+ where you are, and a mark would eat room search and the CTA need.
135
+ 2. search pill -> icon button. The input and the ⌘K hint hide and the glyph
136
+ centres in the same box. A bare icon would collapse to ~20px.
137
+ The language control and live clock hide outright: ambient status, not worth
138
+ a target at this width. */
139
+ .topbar.is-compact { gap: var(--space-2); padding: 0 var(--space-3); }
140
+ /* No brand at this width: the menu button and the account already say where
141
+ you are, and the mark would eat room the search and primary action need. */
142
+ .topbar.is-compact .tb-mark-full,
143
+ .topbar.is-compact .tb-mark-symbol { display: none; }
144
+ .topbar.is-compact .pill-search {
145
+ flex: 0 0 var(--tb-control-h);
146
+ width: var(--tb-control-h);
147
+ max-width: none;
148
+ padding: 0;
149
+ justify-content: center;
150
+ cursor: pointer;
151
+ }
152
+ .topbar.is-compact .pill-search input,
153
+ .topbar.is-compact .pill-search .kbd { display: none; }
154
+ .topbar.is-compact .pill-search > i,
155
+ .topbar.is-compact .pill-search > svg { margin: 0; }
156
+ .topbar.is-compact .tb-live,
157
+ .topbar.is-compact .tb-lang { display: none; }
158
+
159
+ /* The breakpoint opts every bar in. Selectors mirror the block above on purpose:
160
+ plain CSS cannot hand a class to a media query, and a custom-property switch
161
+ for `display` would hide the intent. */
97
162
  @media (max-width: 720px) {
98
163
  .topbar { gap: var(--space-2); padding: 0 var(--space-3); }
164
+ .topbar .tb-mark-full,
165
+ .topbar .tb-mark-symbol { display: none; }
166
+ .topbar .pill-search {
167
+ flex: 0 0 var(--tb-control-h);
168
+ width: var(--tb-control-h);
169
+ max-width: none;
170
+ padding: 0;
171
+ justify-content: center;
172
+ cursor: pointer;
173
+ }
174
+ .topbar .pill-search input,
175
+ .topbar .pill-search .kbd { display: none; }
176
+ .topbar .pill-search > i,
177
+ .topbar .pill-search > svg { margin: 0; }
178
+ .topbar .tb-live,
179
+ .topbar .tb-lang { display: none; }
99
180
  }
100
181
 
182
+ /* Above the breakpoint the symbol stays out of the way */
183
+ .topbar .tb-mark-symbol { display: none; }
184
+
101
185
  /* ===== Account menu (avatar + identity → dropdown) ===== */
102
186
  .tb-account-wrap { position: relative; margin-left: var(--space-1); }
103
187
  .tb-account {
104
- height: var(--tb-control-h); min-height: var(--tb-control-h); box-sizing: border-box;
188
+ height: var(--tb-control-h, var(--control-h-md)); min-height: var(--tb-control-h, var(--control-h-md)); box-sizing: border-box;
105
189
  display: inline-flex; align-items: center; gap: var(--space-2);
106
190
  padding: 0 var(--space-3-5) 0 var(--space-2-5);
107
191
  border: 1px solid var(--color-border-default);
@@ -147,3 +231,79 @@
147
231
  .tb-account-menu-item.is-danger { color: var(--color-feedback-danger-foreground); }
148
232
  .tb-account-menu-item.is-danger svg, .tb-account-menu-item.is-danger i { color: var(--color-feedback-danger-foreground); }
149
233
  .tb-account-menu-item.is-danger:hover { background: var(--color-feedback-danger-background); color: var(--color-feedback-danger-foreground); }
234
+
235
+ /* ============================================================
236
+ GLASS VARIANT — .topbar.is-glass
237
+ ============================================================
238
+ Frosted chrome instead of a solid surface: the bar floats over content and
239
+ lets it show through. Consumes the semantic --glass-chrome pair, so it flips
240
+ to ink glass on dark with no per-theme rule here.
241
+
242
+ Two conditions it depends on, both easy to get wrong:
243
+ 1. Something must scroll UNDER it. Over a flat canvas the blur has nothing to
244
+ sample and the bar just reads as a slightly muddier surface — use the solid
245
+ default there.
246
+ 2. It must be sticky or fixed. A static frosted bar blurs whatever happens to
247
+ sit behind it in the layout, which is usually nothing.
248
+
249
+ The bottom hairline gets a touch more contrast than the solid variant: with a
250
+ translucent background the boundary is the only thing separating bar from
251
+ content. */
252
+ .topbar.is-glass {
253
+ background: var(--glass-chrome);
254
+ color: var(--glass-chrome-fg);
255
+ -webkit-backdrop-filter: var(--glass-blur);
256
+ backdrop-filter: var(--glass-blur);
257
+ border-bottom-color: var(--color-border-strong);
258
+ }
259
+ /* Sticky is what makes the effect mean anything */
260
+ .topbar.is-glass.is-sticky {
261
+ position: sticky;
262
+ top: 0;
263
+ z-index: 800;
264
+ }
265
+ /* No backdrop-filter (older Safari, Firefox with the flag off): fall back to an
266
+ opaque surface rather than a washed-out translucent bar with unreadable text. */
267
+ @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
268
+ .topbar.is-glass { background: var(--color-background-surface); }
269
+ }
270
+
271
+ /* ============================================================
272
+ SIZES — bar-level and per-control
273
+ ============================================================
274
+ Everything in the bar reads --tb-control-h, so the honest lever is the BAR:
275
+ .is-sm / .is-lg retune the token and controls, search and account scale
276
+ together. Per-control .sm / .lg exist for the one-off case — a dense filter
277
+ pill beside standard controls — and deliberately do NOT change the bar height,
278
+ so a smaller control still sits centred in the same row.
279
+
280
+ Bar height tracks the control with room to breathe (control + 24px), which is
281
+ why it is set alongside the token rather than derived. */
282
+ .topbar.is-sm { height: 56px; --tb-control-h: var(--control-h-sm); }
283
+ .topbar.is-lg { height: 72px; --tb-control-h: var(--control-h-lg); }
284
+
285
+ .topbar.is-sm .tb-control,
286
+ .tb-control.sm { font-size: var(--fs-xs); padding: 0 var(--space-3); gap: var(--space-2); }
287
+ .topbar.is-lg .tb-control,
288
+ .tb-control.lg { font-size: var(--fs-md); padding: 0 var(--space-4-5); }
289
+
290
+ /* Per-control override: height only, so it centres inside the standard row */
291
+ .tb-control.sm { height: var(--control-h-sm); min-height: var(--control-h-sm); }
292
+ .tb-control.lg { height: var(--control-h-lg); min-height: var(--control-h-lg); }
293
+ .tb-control.sm.icon { width: var(--control-h-sm); }
294
+ .tb-control.lg.icon { width: var(--control-h-lg); }
295
+ .tb-control.sm > i, .tb-control.sm > svg,
296
+ .tb-control.sm .badge-anchor > i, .tb-control.sm .badge-anchor > svg { width: 12px; height: 12px; }
297
+ .tb-control.lg > i, .tb-control.lg > svg,
298
+ .tb-control.lg .badge-anchor > i, .tb-control.lg .badge-anchor > svg { width: 18px; height: 18px; }
299
+
300
+ /* Live indicator follows the same scale */
301
+ .tb-live.sm .lbl { font-size: var(--fs-xs); }
302
+ .tb-live.sm .dot { width: 5px; height: 5px; }
303
+ .tb-live.lg .lbl { font-size: var(--fs-md); }
304
+ .tb-live.lg .dot { width: 7px; height: 7px; }
305
+ .tb-live.lg .tb-clock { font-size: var(--fs-xs); }
306
+
307
+ /* Avatar and account keep pace with the bar */
308
+ .topbar.is-sm .tb-avatar { width: var(--control-h-xs); height: var(--control-h-xs); font-size: 10px; }
309
+ .topbar.is-lg .tb-avatar { width: var(--control-h-md); height: var(--control-h-md); font-size: var(--fs-sm); }
@@ -41,11 +41,16 @@
41
41
  --color-neutral-900: #0A0F1F; /* inverse surface (never pure black) */
42
42
  --color-neutral-950: #05081A; /* deepest inverse */
43
43
 
44
- /* --- Inverse "ink" tones (variants of neutral-900/-950, used by inverse layer) --- */
45
- --color-ink-base: #0B1230; /* near-black navy */
46
- --color-ink-raised: #111A3D; /* card on inverse */
47
- --color-ink-deep: #0A1128; /* deeper than base — wells, alt rows */
48
- --color-ink-hover: #15214A; /* hover state on inverse */
44
+ /* --- Inverse "ink" tones (dark canvas + the base the inverse layer tints) ---
45
+ Hue 226 held, lightness of every step held, saturation cut 56–63% → ~16%.
46
+ Above ~30% the eye names a background "navy" instead of reading it as a
47
+ near-black; at 16% the blue stops being a colour and becomes a temperature.
48
+ Contrast survives the change: text on canvas 17.27 → 16.5, and card-vs-canvas
49
+ separation improves slightly (1.10 → 1.15). */
50
+ --color-ink-base: #191B22; /* near-black, faintly blue */
51
+ --color-ink-raised: #21232D; /* card on inverse */
52
+ --color-ink-deep: #15171D; /* deeper than base — wells, alt rows */
53
+ --color-ink-hover: #282B37; /* hover state on inverse */
49
54
 
50
55
  /* --- Brand primary · Navy (institutional — logo, headings, primary surfaces)
51
56
  10-stop scale anchored at -500: #002142 (ATTRUS Navy from brand book).
@@ -232,7 +237,7 @@
232
237
  --color-scrim: rgba(10, 15, 31, 0.32);
233
238
  --scrim-blur: 6px;
234
239
  --color-background-inverse-press: color-mix(in srgb, var(--color-ink-hover) 70%, var(--color-pure-white));
235
- --color-background-inverse-selected: color-mix(in srgb, var(--color-brand-solid) 22%, var(--color-ink-base));
240
+ --color-background-inverse-selected: color-mix(in srgb, var(--color-brand-solid) 10%, var(--color-ink-base));
236
241
  --color-background-inverse-brand: var(--color-brand-solid); /* CTA / hero with brand weight — follows product */
237
242
 
238
243
  /* Foreground (text + icons) on LIGHT surfaces */
@@ -264,6 +269,10 @@
264
269
  --color-border-default: color-mix(in srgb, var(--color-ink-base) 7%, transparent);
265
270
  --color-border-strong: color-mix(in srgb, var(--color-ink-base) 12%, transparent);
266
271
  --color-border-focus: var(--color-accent-solid); /* per-theme accent (Sky default) — follows the product's accent */
272
+ /* Hairline width. Every 1px rule in the system was a literal; naming it makes
273
+ the weight adjustable in one place — and a product theme that wants crisper
274
+ separation can raise it without touching 14 component files. */
275
+ --border-hairline: 1px; /* @kind spacing */
267
276
  --color-border-accent: var(--color-accent-solid);
268
277
  --color-border-success: var(--color-success-500);
269
278
  --color-border-warning: var(--color-warning-500);
@@ -567,7 +576,18 @@
567
576
  --space-7: 24px;
568
577
  --space-8: 32px;
569
578
  /* Indent step for a table subrow, multiplied by its depth (see table.css §12) */
570
- --subrow-indent: 24px; /* @kind spacing */
579
+ --subrow-indent: 16px; /* @kind spacing */
580
+
581
+ /* Control height scale — the box every in-line control shares.
582
+ Multiples of 8, so a control, a button and an input on the same row align
583
+ without per-instance tuning. Named to match the button sizes, because those
584
+ were the de-facto scale before this existed: .btn-xs/-sm/(md)/-lg.
585
+ Padding-based items (.sb-item, .tab) stay out of this on purpose — they are
586
+ list and navigation rows, not in-line controls. */
587
+ --control-h-xs: 24px; /* @kind spacing */
588
+ --control-h-sm: 32px; /* @kind spacing */
589
+ --control-h-md: 40px; /* @kind spacing */ /* default */
590
+ --control-h-lg: 48px; /* @kind spacing */
571
591
  --space-9: 40px;
572
592
  --space-10: 48px;
573
593
  --space-12: 64px;
@@ -660,6 +680,13 @@
660
680
  --w-regular: 400; /* @kind font */
661
681
  --w-bold: 700; /* @kind font */
662
682
  --w-black: 900; /* @kind font */
683
+ /* Mono emphasis — the weight amounts use. Kept separate from --w-bold (which
684
+ means Lato 700) because the two families are tuned apart: a future mono swap
685
+ re-tunes this one line without disturbing the sans scale.
686
+ Measured: 700 and 800 resolve to the SAME face here (ink 48.00 for both,
687
+ against 44.00 regular), so 800 bought nothing visually. 700 is the honest
688
+ value — it is what the browser draws and what the import requests. */
689
+ --w-mono-emphasis: 700; /* @kind font */
663
690
  /* RETIRED — Lato has no Medium, SemiBold or ExtraBold. Requesting them from
664
691
  Google Fonts returns nothing and the browser rounds silently (500→400,
665
692
  600→700, 800→900), so the scale advertised 7 steps while rendering 4.
@@ -728,9 +755,9 @@
728
755
  --color-brand-solid-active: var(--color-brand-primary-500); /* settles to navy */
729
756
 
730
757
  /* Inverse layer — stays distinct: a brand-tinted dark, lifted off the ambient ink */
731
- --color-background-inverse: color-mix(in srgb, var(--color-brand-solid) 24%, var(--color-ink-base));
732
- --color-background-inverse-surface: color-mix(in srgb, var(--color-brand-solid) 30%, var(--color-ink-raised));
733
- --color-background-inverse-subtle: color-mix(in srgb, var(--color-brand-solid) 18%, var(--color-ink-deep));
758
+ --color-background-inverse: color-mix(in srgb, var(--color-brand-solid) 11%, var(--color-ink-base));
759
+ --color-background-inverse-surface: color-mix(in srgb, var(--color-brand-solid) 14%, var(--color-ink-raised));
760
+ --color-background-inverse-subtle: color-mix(in srgb, var(--color-brand-solid) 8%, var(--color-ink-deep));
734
761
 
735
762
  /* Foreground — light on dark */
736
763
  --color-foreground-default: var(--color-neutral-50);
@@ -828,6 +855,19 @@ h6, .h6 { font-weight: var(--w-regular); font-size: var(--fs-lg); line-height:
828
855
  font-size-adjust: var(--font-mono-adjust); font-size: .92em; }
829
856
 
830
857
  /* Numbers that must line up in tables */
858
+ /* Visually hidden but announced — for text that a screen reader needs and the
859
+ eye does not. The clip-rect technique, not display:none (which removes it from
860
+ the accessibility tree) and not width:0 (which some engines skip). */
861
+ .sr-only {
862
+ position: absolute;
863
+ width: 1px; height: 1px;
864
+ padding: 0; margin: -1px;
865
+ overflow: hidden;
866
+ clip: rect(0, 0, 0, 0);
867
+ white-space: nowrap;
868
+ border: 0;
869
+ }
870
+
831
871
  .tabular { font-variant-numeric: tabular-nums; font-feature-settings: 'tnum' on, 'lnum' on; }
832
872
 
833
873
  /* ============================================================
@@ -8,10 +8,10 @@ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElemen
8
8
  variant?: ButtonVariant;
9
9
  /** Control height: xs=24, sm=32, md=40 (default), lg=48. */
10
10
  size?: ButtonSize;
11
+ /** Square, label-less button. `aria-label` is required. */
12
+ iconOnly?: boolean;
11
13
  /** Shows the canonical spinner and disables interaction. */
12
14
  loading?: boolean;
13
- /** Square icon-only button (.btn-icon) — width locks to the height. */
14
- iconOnly?: boolean;
15
15
  /** Leading slot — typically an <Icon/>. */
16
16
  icon?: React.ReactNode;
17
17
  /** Trailing slot — e.g. a chevron. */
@@ -23,3 +23,11 @@ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElemen
23
23
  export declare const Button: React.FC<ButtonProps>;
24
24
  export declare const ButtonGroup: React.FC<React.HTMLAttributes<HTMLDivElement>>;
25
25
  export default Button;
26
+
27
+ export interface ButtonGroupProps extends React.HTMLAttributes<HTMLDivElement> {
28
+ children?: React.ReactNode;
29
+ /** Names the cluster for assistive tech. */
30
+ 'aria-label': string;
31
+ }
32
+
33
+ export declare const ButtonGroup: React.FC<ButtonGroupProps>;
@@ -24,10 +24,12 @@ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElemen
24
24
  variant?: ButtonVariant;
25
25
  /** Control height: xs=24, sm=32, md=40 (default), lg=48. */
26
26
  size?: ButtonSize;
27
+ /** Square, label-less button. `aria-label` is REQUIRED — an icon-only
28
+ control has no text node, so without it the button is unnamed to a screen
29
+ reader. That requirement is why this is a prop and not a bare class. */
30
+ iconOnly?: boolean;
27
31
  /** Shows the canonical spinner and disables interaction. */
28
32
  loading?: boolean;
29
- /** Square icon-only button (.btn-icon) — width locks to the height. */
30
- iconOnly?: boolean;
31
33
  /** Leading slot — typically an <Icon/>. */
32
34
  icon?: React.ReactNode;
33
35
  /** Trailing slot — e.g. a chevron. */
@@ -40,8 +42,8 @@ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElemen
40
42
  export const Button: React.FC<ButtonProps> = ({
41
43
  variant = 'primary',
42
44
  size = 'md',
43
- loading = false,
44
45
  iconOnly = false,
46
+ loading = false,
45
47
  icon,
46
48
  iconRight,
47
49
  onInverse = false,
@@ -76,7 +78,13 @@ export default Button;
76
78
 
77
79
  /** Segmented / toolbar cluster — children must be <Button>s (`.btn-group`):
78
80
  * outer corners pill-rounded, inner seams collapsed. */
79
- export const ButtonGroup: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, children, ...rest }) => (
81
+ export interface ButtonGroupProps extends React.HTMLAttributes<HTMLDivElement> {
82
+ children?: React.ReactNode;
83
+ /** Names the cluster for assistive tech — "View mode", "Alignment". */
84
+ 'aria-label': string;
85
+ }
86
+
87
+ export const ButtonGroup: React.FC<ButtonGroupProps> = ({ className, children, ...rest }) => (
80
88
  <div role="group" className={['btn-group', className || ''].filter(Boolean).join(' ')} {...rest}>
81
89
  {children}
82
90
  </div>
@@ -45,6 +45,11 @@ export interface ComboboxProps {
45
45
  disabled?: boolean;
46
46
  /** Gallery/doc rendering: popover stays inline (not portaled). */
47
47
  static?: boolean;
48
+ /** Which trigger edge the portaled popover is pinned to. The popover treats
49
+ the trigger's width as a MINIMUM and grows with its content, so the growth
50
+ needs a direction: 'start' grows right (default), 'end' grows left. Use
51
+ 'end' for a compact control near the right edge. */
52
+ align?: 'start' | 'end';
48
53
  className?: string;
49
54
  style?: React.CSSProperties;
50
55
  'aria-label'?: string;
@@ -53,6 +53,12 @@ export interface ComboboxProps {
53
53
  /** Gallery/doc rendering: popover stays inline (absolute under the trigger),
54
54
  not portaled. Use inside relative frames for side-by-side examples. */
55
55
  static?: boolean;
56
+ /** Which trigger edge the portaled popover is pinned to. It matches the
57
+ trigger's width as a MINIMUM and grows with its content, so the growth has
58
+ to have a direction: 'start' grows right (default), 'end' grows left. Use
59
+ 'end' for a compact control near the right edge, where growing right would
60
+ run off the viewport. */
61
+ align?: 'start' | 'end';
56
62
  className?: string;
57
63
  style?: React.CSSProperties;
58
64
  'aria-label'?: string;
@@ -93,6 +99,7 @@ export const Combobox: React.FC<ComboboxProps> = ({
93
99
  emptySub,
94
100
  disabled = false,
95
101
  static: isStatic = false,
102
+ align = 'start',
96
103
  className,
97
104
  style,
98
105
  ...rest
@@ -102,14 +109,15 @@ export const Combobox: React.FC<ComboboxProps> = ({
102
109
  const [active, setActive] = React.useState<string | null>(null);
103
110
  const wrapRef = React.useRef<HTMLDivElement>(null);
104
111
  const popRef = React.useRef<HTMLDivElement>(null);
105
- const [pos, setPos] = React.useState<{ left: number; top: number; width: number } | null>(null);
112
+ const [pos, setPos] = React.useState<{ left: number; right: number; top: number; width: number } | null>(null);
106
113
 
107
114
  // Position the portaled popover under the trigger (fixed coords from rect).
115
+ // Both edges are captured because `align` decides which one anchors.
108
116
  React.useLayoutEffect(() => {
109
117
  if (!open || isStatic) return undefined;
110
118
  const place = () => {
111
119
  const r = wrapRef.current?.getBoundingClientRect();
112
- if (r) setPos({ left: r.left, top: r.bottom + 8, width: r.width });
120
+ if (r) setPos({ left: r.left, right: window.innerWidth - r.right, top: r.bottom + 8, width: r.width });
113
121
  };
114
122
  place();
115
123
  window.addEventListener('scroll', place, true);
@@ -284,8 +292,22 @@ export const Combobox: React.FC<ComboboxProps> = ({
284
292
  {open && pos
285
293
  ? createPortal(
286
294
  <div
287
- className={['combobox', 'is-open', status === 'error' ? 'is-invalid' : ''].filter(Boolean).join(' ')}
288
- style={{ position: 'fixed', left: pos.left, top: pos.top, width: pos.width, margin: 0, zIndex: 7000 }}
295
+ className={['combobox', 'is-open', status === 'error' ? 'is-invalid' : '', className || ''].filter(Boolean).join(' ')}
296
+ style={{
297
+ /* MIN-width, not width: matching the trigger is right for a form
298
+ field, but as a fixed width it traps the list — a 50px chip
299
+ crushes its own options with no floor to rescue them. The
300
+ trigger sets the floor; content sets the rest. */
301
+ position: 'fixed',
302
+ top: pos.top,
303
+ ...(align === 'end' ? { right: pos.right } : { left: pos.left }),
304
+ minWidth: pos.width,
305
+ margin: 0,
306
+ zIndex: 7000,
307
+ /* The consumer's own style comes last so it can override the
308
+ floor when the content still needs more room. */
309
+ ...style,
310
+ }}
289
311
  onKeyDown={onWrapKeyDown}
290
312
  >
291
313
  <div className="combobox-popover" ref={popRef} style={{ position: 'static' }}>{popoverInner}</div>