@axiapps/axi-design 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/axi.css CHANGED
@@ -157,6 +157,8 @@ a { color: inherit; }
157
157
  .axi-pill:hover,
158
158
  .axi-pill[aria-pressed="true"]:hover,
159
159
  .axi-select:hover,
160
+ .axi-picker__btn:hover,
161
+ .axi-picker__btn[aria-expanded="true"],
160
162
  .axi-card:hover,
161
163
  .axi-drawer__close:hover {
162
164
  transform: none !important;
@@ -400,8 +402,11 @@ a { color: inherit; }
400
402
  /* ---------- select ---------- */
401
403
  /* The closed box is ours everywhere: strip the native control and draw the
402
404
  caret, so a select sits alongside the other controls as just another
403
- outlined chip instead of announcing the OS. */
404
- .axi-select {
405
+ outlined chip instead of announcing the OS. .axi-picker__btn is the same
406
+ box worn by a button instead of a <select>; the two share every
407
+ declaration here so a dropdown reads the same whichever half opens it. */
408
+ .axi-select,
409
+ .axi-picker__btn {
405
410
  appearance: none;
406
411
  padding: 10px 30px 10px 9px;
407
412
  border: var(--axi-border-control) solid var(--axi-ink-line);
@@ -422,7 +427,13 @@ a { color: inherit; }
422
427
  background-repeat: no-repeat;
423
428
  transition: transform .1s, box-shadow .1s;
424
429
  }
425
- .axi-select:hover {
430
+ /* The open trigger keeps the lift. Rule 4 gives the raise to a pointer, but a
431
+ disclosure that drops back flat the moment the pointer moves into the list
432
+ it opened severs the two: the lift is what says this box and that popover
433
+ are one control. */
434
+ .axi-select:hover,
435
+ .axi-picker__btn:hover,
436
+ .axi-picker__btn[aria-expanded='true'] {
426
437
  color: var(--axi-text);
427
438
  box-shadow: var(--axi-offset-control) var(--axi-offset-control) 0 var(--axi-ink-line);
428
439
  transform: translate(-2px, -2px);
@@ -431,7 +442,9 @@ a { color: inherit; }
431
442
  /* The popup stays OS chrome until a browser lets us style it. Where one does
432
443
  (Chromium's base-select), the list is drawn with the same outline and offset
433
444
  block as our own popovers, so both dropdown kinds read as one family; where
434
- it does not, the closed box above is still ours and the list is native. */
445
+ it does not, the closed box above is still ours and the list is native -
446
+ and if that native list is not good enough, .axi-picker below draws the
447
+ whole thing. */
435
448
  @supports (appearance: base-select) {
436
449
  /* base-select draws its own ::picker-icon, so the hand-drawn caret above
437
450
  would be a second arrow. */
@@ -466,6 +479,101 @@ a { color: inherit; }
466
479
  .axi-select option::checkmark { content: "\2713"; color: var(--axi-accent); font-weight: 900; }
467
480
  }
468
481
 
482
+ /* ---------- picker ---------- */
483
+ /* The select's other half. Above, the native popup is left as OS chrome
484
+ wherever `appearance: base-select` is missing - and that is most places
485
+ today, including every Electron built on a Chromium older than the
486
+ property. What lands there is a raised list the language cannot reach: no
487
+ ink outline, no offset block, its own selection colour instead of the
488
+ accent. That is rule 3 broken by a box we do not own, so the fix is to stop
489
+ asking the OS to draw it. .axi-picker is a button and a popover wearing the
490
+ closed box and the list styling from the @supports branch above, so the two
491
+ kinds are the same dropdown and a consumer picks by what the platform has.
492
+
493
+ Prefer the native <select> where it works: it comes with keyboard handling,
494
+ typeahead, and a popup that can escape the window. This is what you use
495
+ when it does not.
496
+
497
+ Markup contract - the listbox pattern, and the popover carries the panel
498
+ weight because it is a raised surface, not a control:
499
+
500
+ <div class="axi-picker">
501
+ <button class="axi-picker__btn" aria-haspopup="listbox"
502
+ aria-expanded="false" aria-controls="months">Jul 2026</button>
503
+ <div class="axi-picker__pop" id="months" role="listbox" hidden>
504
+ <button class="axi-picker__opt" role="option" aria-selected="true">
505
+ Jul 2026
506
+ </button>
507
+ </div>
508
+ </div>
509
+
510
+ Like the menu and the tooltip, the language ships no script: `hidden`,
511
+ `aria-expanded` and `aria-selected` are the whole state, and the consumer
512
+ toggles them. gallery.js is the reference wiring, arrow keys and Escape
513
+ included. */
514
+ .axi-picker { position: relative; display: inline-flex; }
515
+ .axi-picker__btn {
516
+ display: inline-flex; align-items: center;
517
+ width: 100%;
518
+ text-align: left;
519
+ }
520
+ .axi-picker__pop {
521
+ /* Above .axi-mast's z-index: 40, for the same reason .axi-menu__pop is. */
522
+ position: absolute; top: calc(100% + 9px); left: 0; z-index: 41;
523
+ /* Never narrower than the box it came out of, and no wider than its
524
+ longest row needs. A list that shrinks to the text is a list that has
525
+ moved, and the eye has to find the column again. */
526
+ min-width: 100%;
527
+ max-height: 340px; overflow-y: auto;
528
+ padding: 6px;
529
+ background: var(--axi-surface-raised);
530
+ border: var(--axi-border-panel) solid var(--axi-ink-line);
531
+ border-radius: var(--axi-radius);
532
+ /* The block falls outside the popover's own box. An ancestor that clips -
533
+ a scrolling pane, a panel with overflow: hidden - eats it, and the only
534
+ shadow on the screen is the one that goes missing. */
535
+ box-shadow: var(--axi-offset-panel) var(--axi-offset-panel) 0 var(--axi-ink-line);
536
+ }
537
+ .axi-picker__pop[hidden] { display: none; }
538
+ /* A popover cannot always live beside its trigger. Inside a pane that scrolls
539
+ or a panel that clips, `position: absolute` puts the list where the
540
+ overflow can eat it - and the offset block falls outside the popover's box,
541
+ so the block is the first thing to go. The way out is the tooltip's
542
+ contract: append the popover to <body> and set left/top from script, having
543
+ measured the trigger. The box is unchanged; only who positions it moves. */
544
+ .axi-picker__pop--fixed { position: fixed; }
545
+ .axi-picker__opt {
546
+ display: flex; align-items: center; gap: 9px;
547
+ width: 100%;
548
+ padding: 8px 9px;
549
+ border: 0;
550
+ border-radius: var(--axi-radius-sm);
551
+ background: transparent;
552
+ color: var(--axi-text-dim);
553
+ font: var(--axi-t-label);
554
+ font-size: 12.5px;
555
+ letter-spacing: var(--axi-ls-label);
556
+ text-transform: uppercase;
557
+ text-align: left;
558
+ cursor: pointer;
559
+ }
560
+ /* The tick is in every row, inked only in the chosen one. Give it to the
561
+ selected row alone and every label shifts by its width as the choice moves,
562
+ which turns picking an option into the list twitching. */
563
+ .axi-picker__opt::before {
564
+ content: "\2713";
565
+ color: transparent;
566
+ font-weight: 900;
567
+ }
568
+ .axi-picker__opt:hover, .axi-picker__opt:focus {
569
+ background: var(--axi-ground); color: var(--axi-text);
570
+ }
571
+ /* The page-wide focus ring sits 2px outside its element; inside a popover
572
+ this tight that is 2px into the neighbouring row, so pull it back in. */
573
+ .axi-picker__opt:focus-visible { outline-offset: -3px; }
574
+ .axi-picker__opt[aria-selected='true'] { color: var(--axi-text); }
575
+ .axi-picker__opt[aria-selected='true']::before { color: var(--axi-accent); }
576
+
469
577
  /* --- layout.css --- */
470
578
  /* axi design language - layout.
471
579
  Three measures, one grid, two spacing helpers. Deliberately small: a
package/docs/RULES.md CHANGED
@@ -111,6 +111,17 @@ read `--axi-radius`, everything control-sized reads `--axi-radius-sm`, and
111
111
  `tests/tokens.test.mjs` fails on a literal radius in a component file the same
112
112
  way it fails on a literal border weight.
113
113
 
114
+ **A box the OS draws is a box that breaks this.** A native `<select>` popup is
115
+ a raised list the language cannot reach: no ink outline, no offset block, and
116
+ its own selection colour where the accent belongs. `.axi-select` styles the
117
+ closed box and hands the list to `appearance: base-select` where the browser
118
+ has it — but most do not yet, and an Electron app is pinned to whatever
119
+ Chromium its version shipped. `.axi-picker` is the way out: the same closed
120
+ box on a button, and the list drawn as a popover that takes the panel weight
121
+ like any other raised surface. Reach for the native select first, because it
122
+ brings keyboard handling and a popup that can leave the window; reach for the
123
+ picker when the popup it opens is not one this rule can touch.
124
+
114
125
  ## 4. Hover lifts
115
126
 
116
127
  The lift is per form step, not one universal number: a control has no resting
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiapps/axi-design",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "The design language for the axi suite — flat and outlined, dark, drawn in saturated ink.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/base.css CHANGED
@@ -59,6 +59,8 @@ a { color: inherit; }
59
59
  .axi-pill:hover,
60
60
  .axi-pill[aria-pressed="true"]:hover,
61
61
  .axi-select:hover,
62
+ .axi-picker__btn:hover,
63
+ .axi-picker__btn[aria-expanded="true"],
62
64
  .axi-card:hover,
63
65
  .axi-drawer__close:hover {
64
66
  transform: none !important;
@@ -234,8 +234,11 @@
234
234
  /* ---------- select ---------- */
235
235
  /* The closed box is ours everywhere: strip the native control and draw the
236
236
  caret, so a select sits alongside the other controls as just another
237
- outlined chip instead of announcing the OS. */
238
- .axi-select {
237
+ outlined chip instead of announcing the OS. .axi-picker__btn is the same
238
+ box worn by a button instead of a <select>; the two share every
239
+ declaration here so a dropdown reads the same whichever half opens it. */
240
+ .axi-select,
241
+ .axi-picker__btn {
239
242
  appearance: none;
240
243
  padding: 10px 30px 10px 9px;
241
244
  border: var(--axi-border-control) solid var(--axi-ink-line);
@@ -256,7 +259,13 @@
256
259
  background-repeat: no-repeat;
257
260
  transition: transform .1s, box-shadow .1s;
258
261
  }
259
- .axi-select:hover {
262
+ /* The open trigger keeps the lift. Rule 4 gives the raise to a pointer, but a
263
+ disclosure that drops back flat the moment the pointer moves into the list
264
+ it opened severs the two: the lift is what says this box and that popover
265
+ are one control. */
266
+ .axi-select:hover,
267
+ .axi-picker__btn:hover,
268
+ .axi-picker__btn[aria-expanded='true'] {
260
269
  color: var(--axi-text);
261
270
  box-shadow: var(--axi-offset-control) var(--axi-offset-control) 0 var(--axi-ink-line);
262
271
  transform: translate(-2px, -2px);
@@ -265,7 +274,9 @@
265
274
  /* The popup stays OS chrome until a browser lets us style it. Where one does
266
275
  (Chromium's base-select), the list is drawn with the same outline and offset
267
276
  block as our own popovers, so both dropdown kinds read as one family; where
268
- it does not, the closed box above is still ours and the list is native. */
277
+ it does not, the closed box above is still ours and the list is native -
278
+ and if that native list is not good enough, .axi-picker below draws the
279
+ whole thing. */
269
280
  @supports (appearance: base-select) {
270
281
  /* base-select draws its own ::picker-icon, so the hand-drawn caret above
271
282
  would be a second arrow. */
@@ -299,3 +310,98 @@
299
310
  .axi-select option:checked { color: var(--axi-text); }
300
311
  .axi-select option::checkmark { content: "\2713"; color: var(--axi-accent); font-weight: 900; }
301
312
  }
313
+
314
+ /* ---------- picker ---------- */
315
+ /* The select's other half. Above, the native popup is left as OS chrome
316
+ wherever `appearance: base-select` is missing - and that is most places
317
+ today, including every Electron built on a Chromium older than the
318
+ property. What lands there is a raised list the language cannot reach: no
319
+ ink outline, no offset block, its own selection colour instead of the
320
+ accent. That is rule 3 broken by a box we do not own, so the fix is to stop
321
+ asking the OS to draw it. .axi-picker is a button and a popover wearing the
322
+ closed box and the list styling from the @supports branch above, so the two
323
+ kinds are the same dropdown and a consumer picks by what the platform has.
324
+
325
+ Prefer the native <select> where it works: it comes with keyboard handling,
326
+ typeahead, and a popup that can escape the window. This is what you use
327
+ when it does not.
328
+
329
+ Markup contract - the listbox pattern, and the popover carries the panel
330
+ weight because it is a raised surface, not a control:
331
+
332
+ <div class="axi-picker">
333
+ <button class="axi-picker__btn" aria-haspopup="listbox"
334
+ aria-expanded="false" aria-controls="months">Jul 2026</button>
335
+ <div class="axi-picker__pop" id="months" role="listbox" hidden>
336
+ <button class="axi-picker__opt" role="option" aria-selected="true">
337
+ Jul 2026
338
+ </button>
339
+ </div>
340
+ </div>
341
+
342
+ Like the menu and the tooltip, the language ships no script: `hidden`,
343
+ `aria-expanded` and `aria-selected` are the whole state, and the consumer
344
+ toggles them. gallery.js is the reference wiring, arrow keys and Escape
345
+ included. */
346
+ .axi-picker { position: relative; display: inline-flex; }
347
+ .axi-picker__btn {
348
+ display: inline-flex; align-items: center;
349
+ width: 100%;
350
+ text-align: left;
351
+ }
352
+ .axi-picker__pop {
353
+ /* Above .axi-mast's z-index: 40, for the same reason .axi-menu__pop is. */
354
+ position: absolute; top: calc(100% + 9px); left: 0; z-index: 41;
355
+ /* Never narrower than the box it came out of, and no wider than its
356
+ longest row needs. A list that shrinks to the text is a list that has
357
+ moved, and the eye has to find the column again. */
358
+ min-width: 100%;
359
+ max-height: 340px; overflow-y: auto;
360
+ padding: 6px;
361
+ background: var(--axi-surface-raised);
362
+ border: var(--axi-border-panel) solid var(--axi-ink-line);
363
+ border-radius: var(--axi-radius);
364
+ /* The block falls outside the popover's own box. An ancestor that clips -
365
+ a scrolling pane, a panel with overflow: hidden - eats it, and the only
366
+ shadow on the screen is the one that goes missing. */
367
+ box-shadow: var(--axi-offset-panel) var(--axi-offset-panel) 0 var(--axi-ink-line);
368
+ }
369
+ .axi-picker__pop[hidden] { display: none; }
370
+ /* A popover cannot always live beside its trigger. Inside a pane that scrolls
371
+ or a panel that clips, `position: absolute` puts the list where the
372
+ overflow can eat it - and the offset block falls outside the popover's box,
373
+ so the block is the first thing to go. The way out is the tooltip's
374
+ contract: append the popover to <body> and set left/top from script, having
375
+ measured the trigger. The box is unchanged; only who positions it moves. */
376
+ .axi-picker__pop--fixed { position: fixed; }
377
+ .axi-picker__opt {
378
+ display: flex; align-items: center; gap: 9px;
379
+ width: 100%;
380
+ padding: 8px 9px;
381
+ border: 0;
382
+ border-radius: var(--axi-radius-sm);
383
+ background: transparent;
384
+ color: var(--axi-text-dim);
385
+ font: var(--axi-t-label);
386
+ font-size: 12.5px;
387
+ letter-spacing: var(--axi-ls-label);
388
+ text-transform: uppercase;
389
+ text-align: left;
390
+ cursor: pointer;
391
+ }
392
+ /* The tick is in every row, inked only in the chosen one. Give it to the
393
+ selected row alone and every label shifts by its width as the choice moves,
394
+ which turns picking an option into the list twitching. */
395
+ .axi-picker__opt::before {
396
+ content: "\2713";
397
+ color: transparent;
398
+ font-weight: 900;
399
+ }
400
+ .axi-picker__opt:hover, .axi-picker__opt:focus {
401
+ background: var(--axi-ground); color: var(--axi-text);
402
+ }
403
+ /* The page-wide focus ring sits 2px outside its element; inside a popover
404
+ this tight that is 2px into the neighbouring row, so pull it back in. */
405
+ .axi-picker__opt:focus-visible { outline-offset: -3px; }
406
+ .axi-picker__opt[aria-selected='true'] { color: var(--axi-text); }
407
+ .axi-picker__opt[aria-selected='true']::before { color: var(--axi-accent); }