@ambasada/ui 0.7.4 → 0.8.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/README.md +9 -3
- package/dist/ambasada-ui.css +169 -79
- package/dist/components.css +156 -77
- package/dist/react.d.ts +19 -8
- package/dist/react.js +84 -17
- package/dist/tokens.css +12 -1
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -40,9 +40,14 @@ The shell's insets, for anything you position yourself:
|
|
|
40
40
|
|---|---|---|
|
|
41
41
|
| `--ambasada-safe-area-top` / `bottom` / `left` / `right` | `env(safe-area-inset-*, 0px)` | set on `<html>` by `@ambasada/miniapp`'s `ready()` |
|
|
42
42
|
| `--ambasada-host-bar` | `0px` | the height of the shell's top bar |
|
|
43
|
+
| `--ambasada-inset-top` / `bottom` / `left` / `right` | `max(--ambasada-safe-area-*, env(safe-area-inset-*))` | what to keep clear: use these |
|
|
44
|
+
|
|
45
|
+
Use the `--ambasada-inset-*` tokens for anything you place yourself. They take the larger of the shell's report and
|
|
46
|
+
the browser's own inset, so the inset is right inside the app and on the open web and is never counted twice. Keep
|
|
47
|
+
the margin on top of it small (8 px): an empty band above the home indicator is a bug.
|
|
43
48
|
|
|
44
49
|
```css
|
|
45
|
-
.my-bar { position: fixed; bottom: 0; padding-bottom: calc(
|
|
50
|
+
.my-bar { position: fixed; bottom: 0; padding-bottom: calc(8px + var(--ambasada-inset-bottom)); }
|
|
46
51
|
```
|
|
47
52
|
|
|
48
53
|
## Components
|
|
@@ -56,7 +61,8 @@ The shell's insets, for anything you position yourself:
|
|
|
56
61
|
| `ui-group` (`ui-group-head`, `ui-group-body`) | A list: rows in one bordered block, with a title. |
|
|
57
62
|
| `ui-row` (`row-icon`, `row-text`, `row-title`, `row-sub`, `row-right`, `row-chev`) | List row; `tappable`, `danger`, `muted`; `row-icon accent` / `good` / `bad`. |
|
|
58
63
|
| `ui-pill` | Status pill with a dot: `good`, `bad`, `warn`, `accent`, `pulse`. |
|
|
59
|
-
| `ui-sheet-backdrop` > `ui-sheet` (`ui-sheet-grip
|
|
64
|
+
| `ui-sheet-backdrop` > `ui-sheet` (`ui-sheet-handle` > `ui-sheet-grip` + `ui-sheet-head`, `-body`, `-foot`), `ui-icon-btn` | Bottom sheet at every width (no centred dialogs): grip, rounded top, dimmed page, a body that scrolls, the one primary action in the foot. Add `ui-sheet-open` to `<body>` while open. The React `Sheet` also portals to `<body>`, closes on a drag down, and keeps the foot above the keyboard. |
|
|
65
|
+
| `ui-field` (`ui-field-label`, `ui-field-hint`, `.bad`/`.good`), `ui-input` | A form field: label, control, one line of hint or error under it, 8 px apart; compact inside `ui-shell` with a mouse. `ui-input` on `<input>`, `<textarea>`, `<select>` (`mono`, `code`). |
|
|
60
66
|
| `ui-stepper-wrap` > `ui-stepper` (`ui-stepper-value`), `ui-presets` | − value + for amounts, with preset chips (`on`). |
|
|
61
67
|
| `ui-empty` (`ui-empty-icon`) | Empty state: icon, `<strong>` title, hint, action. |
|
|
62
68
|
| `ui-skeleton` | Grey bars (`<span>`s) while loading: skeletons, not spinners. |
|
|
@@ -68,7 +74,7 @@ For tools built for the laptop that still open in the Ambasada app (the Develope
|
|
|
68
74
|
| `ui-shell` > `ui-shell-side`, `ui-shell-scrim`, `ui-shell-main` > `ui-shell-top` (`ui-shell-menu`), `ui-shell-content` | 240 px sidebar, sticky top bar, content up to 1088 px. Under 900 px the sidebar is a drawer (`ui-shell open`). Clears the host bar and safe areas once. |
|
|
69
75
|
| `ui-nav` (`ui-nav-title`), `ui-nav-item` (`ui-nav-label`, `ui-nav-right`, `on`) | Sidebar navigation; the current item gets a red marker. |
|
|
70
76
|
| `ui-tabs` | Tabs as links under a title (`a.on`); scrolls sideways when narrow. |
|
|
71
|
-
| `ui-field
|
|
77
|
+
| `ui-field`, `ui-input` | The same field as above, compact here with a mouse (40 px, 14 px text). `<Field label value onChange />` renders the input itself. |
|
|
72
78
|
| `ui-table` (`.num`, `tr.tappable`) | Data table: hairline rows, tabular figures. |
|
|
73
79
|
|
|
74
80
|
```html
|
package/dist/ambasada-ui.css
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* @ambasada/ui 0.
|
|
1
|
+
/* @ambasada/ui 0.8.0: fonts, tokens and components in one stylesheet. MIT, CENTYSO. https://ambasada.id */
|
|
2
2
|
/*
|
|
3
3
|
* Inter and JetBrains Mono, self-hosted (variable, SIL Open Font License 1.1: fonts/OFL-*.txt). Latin and
|
|
4
4
|
* Latin Extended, so Romanian ț and ș render. The URLs are relative to this file: bundlers copy the files,
|
|
@@ -158,7 +158,18 @@
|
|
|
158
158
|
--ambasada-safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
|
159
159
|
--ambasada-safe-area-left: env(safe-area-inset-left, 0px);
|
|
160
160
|
--ambasada-safe-area-right: env(safe-area-inset-right, 0px);
|
|
161
|
-
--ambasada-host-bar: 0px; /* height of the shell's top bar (
|
|
161
|
+
--ambasada-host-bar: 0px; /* height of the shell's top bar (close, app name, "…" menu) */
|
|
162
|
+
|
|
163
|
+
/*
|
|
164
|
+
* What a page actually keeps clear, per side: the larger of the shell's report and the browser's own inset.
|
|
165
|
+
* A shell that reports 0 while its WebView runs under the home indicator, and a browser that knows nothing of
|
|
166
|
+
* the shell, both come out right, and an inset is never counted twice (docs/MINI-APPS.md §15, decision 11).
|
|
167
|
+
* Use these (plus --ambasada-host-bar at the top), never env() or the raw --ambasada-safe-area-* directly.
|
|
168
|
+
*/
|
|
169
|
+
--ambasada-inset-top: max(var(--ambasada-safe-area-top), env(safe-area-inset-top, 0px));
|
|
170
|
+
--ambasada-inset-bottom: max(var(--ambasada-safe-area-bottom), env(safe-area-inset-bottom, 0px));
|
|
171
|
+
--ambasada-inset-left: max(var(--ambasada-safe-area-left), env(safe-area-inset-left, 0px));
|
|
172
|
+
--ambasada-inset-right: max(var(--ambasada-safe-area-right), env(safe-area-inset-right, 0px));
|
|
162
173
|
}
|
|
163
174
|
|
|
164
175
|
/*
|
|
@@ -198,10 +209,10 @@
|
|
|
198
209
|
max-width: var(--page-max);
|
|
199
210
|
margin: 0 auto;
|
|
200
211
|
padding:
|
|
201
|
-
calc(var(--space-4) + var(--ambasada-
|
|
202
|
-
calc(var(--space-4) + var(--ambasada-
|
|
203
|
-
calc(var(--space-8) + var(--ambasada-
|
|
204
|
-
calc(var(--space-4) + var(--ambasada-
|
|
212
|
+
calc(var(--space-4) + var(--ambasada-inset-top) + var(--ambasada-host-bar, 0px))
|
|
213
|
+
calc(var(--space-4) + var(--ambasada-inset-right))
|
|
214
|
+
calc(var(--space-8) + var(--ambasada-inset-bottom))
|
|
215
|
+
calc(var(--space-4) + var(--ambasada-inset-left));
|
|
205
216
|
display: flex;
|
|
206
217
|
flex-direction: column;
|
|
207
218
|
gap: var(--space-4);
|
|
@@ -438,73 +449,110 @@
|
|
|
438
449
|
}
|
|
439
450
|
|
|
440
451
|
/* ── bottom sheet ───────────────────────────────────────────────────────────
|
|
441
|
-
*
|
|
452
|
+
* Anything that opens over a page is a bottom sheet, at every width: never a centred dialog (docs/MINI-APPS.md
|
|
453
|
+
* §15, decision 12). A grip and a head you can drag down, a body that scrolls, a foot with the one primary
|
|
454
|
+
* action. The React Sheet portals it to <body> (so no transformed ancestor can trap it) and sizes the backdrop
|
|
455
|
+
* to the visual viewport (--ui-vv-top, --ui-vv-height, --ui-keyboard), so the foot stays above the keyboard. */
|
|
442
456
|
|
|
443
457
|
body.ui-sheet-open { overflow: hidden; }
|
|
444
458
|
.ui-sheet-backdrop {
|
|
445
459
|
position: fixed;
|
|
446
|
-
|
|
460
|
+
left: 0;
|
|
461
|
+
right: 0;
|
|
462
|
+
top: var(--ui-vv-top, 0px);
|
|
463
|
+
height: var(--ui-vv-height, 100%);
|
|
447
464
|
z-index: 50;
|
|
448
|
-
background: var(--color-backdrop);
|
|
449
465
|
display: flex;
|
|
450
|
-
|
|
451
|
-
justify-content:
|
|
466
|
+
flex-direction: column;
|
|
467
|
+
justify-content: flex-end;
|
|
468
|
+
align-items: center;
|
|
469
|
+
padding-top: calc(var(--ambasada-inset-top) + var(--ambasada-host-bar, 0px) + var(--space-2));
|
|
470
|
+
background: var(--color-backdrop);
|
|
452
471
|
animation: ui-fade var(--duration) var(--ease);
|
|
453
472
|
}
|
|
454
473
|
.ui-sheet {
|
|
455
474
|
width: 100%;
|
|
456
475
|
max-width: var(--page-max);
|
|
457
|
-
max-height:
|
|
476
|
+
max-height: 100%;
|
|
477
|
+
min-height: 0;
|
|
458
478
|
display: flex;
|
|
459
479
|
flex-direction: column;
|
|
460
480
|
background: var(--color-bg);
|
|
461
481
|
border-radius: var(--radius-hero) var(--radius-hero) 0 0;
|
|
462
482
|
box-shadow: var(--shadow-raised);
|
|
463
|
-
animation: ui-rise
|
|
464
|
-
|
|
483
|
+
animation: ui-rise 320ms var(--ease);
|
|
484
|
+
/* The home indicator stays clear; with the keyboard up it is covered anyway, so the inset goes. */
|
|
485
|
+
padding-bottom: max(0px, calc(var(--ambasada-inset-bottom) - var(--ui-keyboard, 0px)));
|
|
486
|
+
padding-left: var(--ambasada-inset-left);
|
|
487
|
+
padding-right: var(--ambasada-inset-right);
|
|
488
|
+
will-change: transform;
|
|
489
|
+
}
|
|
490
|
+
.ui-sheet-backdrop.leaving { animation: ui-fade-out 200ms var(--ease) both; }
|
|
491
|
+
.ui-sheet-backdrop.leaving .ui-sheet { animation: ui-sink 200ms var(--ease) both; }
|
|
492
|
+
/* Grip and head: the drag handle. touch-action none so a drag moves the sheet, not the page. */
|
|
493
|
+
.ui-sheet-handle {
|
|
494
|
+
flex: none;
|
|
495
|
+
touch-action: none;
|
|
496
|
+
cursor: grab;
|
|
497
|
+
-webkit-user-select: none;
|
|
498
|
+
user-select: none;
|
|
465
499
|
}
|
|
466
500
|
.ui-sheet-grip {
|
|
467
501
|
width: 36px;
|
|
468
|
-
height:
|
|
469
|
-
margin:
|
|
470
|
-
border-radius:
|
|
502
|
+
height: 5px;
|
|
503
|
+
margin: var(--space-2) auto 0;
|
|
504
|
+
border-radius: 3px;
|
|
471
505
|
background: var(--color-line-strong);
|
|
472
506
|
}
|
|
473
507
|
.ui-sheet-head {
|
|
474
508
|
display: flex;
|
|
475
509
|
align-items: center;
|
|
476
510
|
justify-content: space-between;
|
|
477
|
-
|
|
511
|
+
gap: var(--space-3);
|
|
512
|
+
min-height: 52px;
|
|
513
|
+
padding: var(--space-1) var(--space-3) var(--space-1) var(--space-5);
|
|
478
514
|
}
|
|
479
515
|
.ui-sheet-head h2 {
|
|
516
|
+
flex: 1;
|
|
517
|
+
min-width: 0;
|
|
480
518
|
margin: 0;
|
|
481
519
|
font-size: var(--text-xl);
|
|
520
|
+
font-weight: var(--weight-semibold);
|
|
521
|
+
letter-spacing: var(--tracking-tight);
|
|
522
|
+
line-height: 1.2;
|
|
523
|
+
overflow-wrap: anywhere;
|
|
482
524
|
}
|
|
525
|
+
.ui-sheet-head .ui-icon-btn { flex: none; }
|
|
483
526
|
.ui-sheet-body {
|
|
527
|
+
flex: 1 1 auto;
|
|
528
|
+
min-height: 0;
|
|
484
529
|
overflow-y: auto;
|
|
485
|
-
|
|
530
|
+
overscroll-behavior: contain;
|
|
531
|
+
-webkit-overflow-scrolling: touch;
|
|
532
|
+
padding: var(--space-3) var(--space-5) var(--space-5);
|
|
486
533
|
display: flex;
|
|
487
534
|
flex-direction: column;
|
|
488
|
-
gap: var(--space-
|
|
535
|
+
gap: var(--space-5);
|
|
489
536
|
}
|
|
537
|
+
.ui-sheet-body > * { flex: none; }
|
|
490
538
|
.ui-sheet-foot {
|
|
491
|
-
|
|
539
|
+
flex: none;
|
|
540
|
+
padding: var(--space-3) var(--space-5);
|
|
492
541
|
border-top: 1px solid var(--color-line);
|
|
493
542
|
display: flex;
|
|
494
543
|
gap: var(--space-2);
|
|
495
544
|
}
|
|
496
545
|
.ui-sheet-foot > button,
|
|
497
|
-
.ui-sheet-foot >
|
|
498
|
-
|
|
546
|
+
.ui-sheet-foot > a,
|
|
547
|
+
.ui-sheet-foot > .ui-button { flex: 1; min-height: 48px; }
|
|
548
|
+
@keyframes ui-rise { from { transform: translate3d(0, 100%, 0); } to { transform: none; } }
|
|
549
|
+
@keyframes ui-sink { from { transform: none; } to { transform: translate3d(0, 100%, 0); } }
|
|
499
550
|
@keyframes ui-fade { from { opacity: 0; } to { opacity: 1; } }
|
|
500
|
-
@
|
|
501
|
-
.ui-sheet-backdrop { align-items: center; padding: var(--space-6); }
|
|
502
|
-
.ui-sheet { border-radius: var(--radius-hero); max-height: 86dvh; }
|
|
503
|
-
.ui-sheet-grip { display: none; }
|
|
504
|
-
}
|
|
551
|
+
@keyframes ui-fade-out { from { opacity: 1; } to { opacity: 0; } }
|
|
505
552
|
|
|
506
|
-
/* Round icon button (the sheet's close). */
|
|
553
|
+
/* Round icon button (the sheet's close): a 36px circle in a 44px target. */
|
|
507
554
|
.ui-icon-btn {
|
|
555
|
+
position: relative;
|
|
508
556
|
width: var(--tap);
|
|
509
557
|
height: var(--tap);
|
|
510
558
|
min-height: 0;
|
|
@@ -512,12 +560,77 @@ body.ui-sheet-open { overflow: hidden; }
|
|
|
512
560
|
display: grid;
|
|
513
561
|
place-items: center;
|
|
514
562
|
border-radius: 50%;
|
|
515
|
-
background:
|
|
563
|
+
background: none;
|
|
516
564
|
color: var(--color-ink-2);
|
|
517
565
|
border: 0;
|
|
518
566
|
cursor: pointer;
|
|
567
|
+
isolation: isolate;
|
|
568
|
+
}
|
|
569
|
+
.ui-icon-btn::before {
|
|
570
|
+
content: "";
|
|
571
|
+
position: absolute;
|
|
572
|
+
inset: 4px;
|
|
573
|
+
z-index: -1;
|
|
574
|
+
border-radius: 50%;
|
|
575
|
+
background: var(--color-surface-2);
|
|
576
|
+
transition: background-color var(--duration) var(--ease);
|
|
519
577
|
}
|
|
520
578
|
.ui-icon-btn:hover { filter: none; color: var(--color-ink); }
|
|
579
|
+
.ui-icon-btn:hover::before { background: var(--color-line); }
|
|
580
|
+
|
|
581
|
+
/* ── fields ─────────────────────────────────────────────────────────────────
|
|
582
|
+
* A label, the control, one line of hint or error (.bad) under it, 8px apart. Inputs are 16px so iOS does not zoom into them. */
|
|
583
|
+
|
|
584
|
+
.ui-field {
|
|
585
|
+
display: flex;
|
|
586
|
+
flex-direction: column;
|
|
587
|
+
gap: var(--space-2);
|
|
588
|
+
min-width: 0;
|
|
589
|
+
}
|
|
590
|
+
.ui-field-label {
|
|
591
|
+
font-size: var(--text-sm);
|
|
592
|
+
font-weight: var(--weight-medium);
|
|
593
|
+
color: var(--color-ink);
|
|
594
|
+
line-height: 1.3;
|
|
595
|
+
}
|
|
596
|
+
.ui-field-hint {
|
|
597
|
+
font-size: var(--text-xs);
|
|
598
|
+
color: var(--color-ink-3);
|
|
599
|
+
line-height: 1.45;
|
|
600
|
+
}
|
|
601
|
+
.ui-field-hint.bad { color: var(--color-danger); }
|
|
602
|
+
.ui-field-hint.good { color: var(--color-success); }
|
|
603
|
+
.ui-input {
|
|
604
|
+
display: block;
|
|
605
|
+
width: 100%;
|
|
606
|
+
min-width: 0;
|
|
607
|
+
min-height: 48px;
|
|
608
|
+
margin: 0;
|
|
609
|
+
padding: 12px 14px;
|
|
610
|
+
font: inherit;
|
|
611
|
+
font-family: var(--font-sans);
|
|
612
|
+
font-size: var(--text-base);
|
|
613
|
+
letter-spacing: normal;
|
|
614
|
+
line-height: 1.4;
|
|
615
|
+
color: var(--color-ink);
|
|
616
|
+
background: var(--color-surface);
|
|
617
|
+
border: 1px solid var(--color-line);
|
|
618
|
+
border-radius: var(--radius-md);
|
|
619
|
+
-webkit-appearance: none;
|
|
620
|
+
appearance: none;
|
|
621
|
+
transition: border-color var(--duration) var(--ease), box-shadow var(--duration) var(--ease);
|
|
622
|
+
}
|
|
623
|
+
.ui-input::placeholder { color: var(--color-ink-3); opacity: 1; }
|
|
624
|
+
.ui-input:hover { border-color: var(--color-line-strong); }
|
|
625
|
+
.ui-input:focus {
|
|
626
|
+
outline: none;
|
|
627
|
+
border-color: var(--color-accent);
|
|
628
|
+
box-shadow: 0 0 0 3px var(--color-accent-soft);
|
|
629
|
+
}
|
|
630
|
+
.ui-input:disabled { background: var(--color-surface-2); color: var(--color-ink-2); border-color: var(--color-line); }
|
|
631
|
+
.ui-input.mono { font-family: var(--font-mono); letter-spacing: 0.02em; }
|
|
632
|
+
.ui-input.code { font-family: var(--font-mono); letter-spacing: 0.12em; text-transform: uppercase; }
|
|
633
|
+
textarea.ui-input { resize: vertical; min-height: 104px; }
|
|
521
634
|
|
|
522
635
|
/* ── stepper ────────────────────────────────────────────────────────────────
|
|
523
636
|
* − value + for amounts, with optional preset chips underneath. */
|
|
@@ -527,19 +640,20 @@ body.ui-sheet-open { overflow: hidden; }
|
|
|
527
640
|
display: flex;
|
|
528
641
|
align-items: center;
|
|
529
642
|
justify-content: space-between;
|
|
643
|
+
min-height: 48px;
|
|
530
644
|
border: 1px solid var(--color-line);
|
|
531
645
|
border-radius: var(--radius-md);
|
|
532
646
|
background: var(--color-surface);
|
|
533
|
-
padding:
|
|
647
|
+
padding: 3px;
|
|
534
648
|
}
|
|
535
649
|
.ui-stepper button {
|
|
536
|
-
width:
|
|
537
|
-
height:
|
|
650
|
+
width: 52px;
|
|
651
|
+
height: 40px;
|
|
538
652
|
min-height: 0;
|
|
539
653
|
padding: 0;
|
|
540
654
|
display: grid;
|
|
541
655
|
place-items: center;
|
|
542
|
-
border-radius:
|
|
656
|
+
border-radius: var(--radius-sm);
|
|
543
657
|
background: var(--color-surface-2);
|
|
544
658
|
color: var(--color-ink);
|
|
545
659
|
border: 0;
|
|
@@ -553,10 +667,11 @@ body.ui-sheet-open { overflow: hidden; }
|
|
|
553
667
|
font-variant-numeric: tabular-nums;
|
|
554
668
|
}
|
|
555
669
|
.ui-stepper-value small { font-size: var(--text-xs); color: var(--color-ink-3); font-weight: var(--weight-regular); }
|
|
556
|
-
.ui-presets { display: flex; gap:
|
|
670
|
+
.ui-presets { display: flex; gap: var(--space-2); flex-wrap: wrap; }
|
|
557
671
|
.ui-presets button {
|
|
558
|
-
min-
|
|
559
|
-
|
|
672
|
+
min-width: 48px;
|
|
673
|
+
min-height: 36px;
|
|
674
|
+
padding: 4px 14px;
|
|
560
675
|
border-radius: var(--radius-pill);
|
|
561
676
|
background: var(--color-surface);
|
|
562
677
|
color: var(--color-ink-2);
|
|
@@ -811,48 +926,21 @@ body.ui-sheet-open { overflow: hidden; }
|
|
|
811
926
|
border-bottom-color: var(--color-ink);
|
|
812
927
|
}
|
|
813
928
|
|
|
814
|
-
/*
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
color: var(--color-danger);
|
|
830
|
-
}
|
|
831
|
-
.ui-field-hint.good {
|
|
832
|
-
color: var(--color-success);
|
|
833
|
-
}
|
|
834
|
-
.ui-input {
|
|
835
|
-
width: 100%;
|
|
836
|
-
min-height: 40px;
|
|
837
|
-
padding: 8px 12px;
|
|
838
|
-
border: 1px solid var(--color-line);
|
|
839
|
-
border-radius: var(--radius-sm);
|
|
840
|
-
background: var(--color-surface);
|
|
841
|
-
color: var(--color-ink);
|
|
842
|
-
font: inherit;
|
|
843
|
-
font-size: var(--text-sm);
|
|
844
|
-
}
|
|
845
|
-
.ui-input:hover {
|
|
846
|
-
border-color: var(--color-line-strong);
|
|
847
|
-
}
|
|
848
|
-
.ui-input:focus {
|
|
849
|
-
outline: none;
|
|
850
|
-
border-color: var(--color-ink);
|
|
851
|
-
box-shadow: 0 0 0 3px var(--color-surface-2);
|
|
852
|
-
}
|
|
853
|
-
.ui-input:disabled {
|
|
854
|
-
background: var(--color-surface-2);
|
|
855
|
-
color: var(--color-ink-3);
|
|
929
|
+
/* Fields inside a desktop tool are compact with a mouse. On a phone they stay 48 px and 16 px, so iOS does not zoom. */
|
|
930
|
+
@media (pointer: fine) {
|
|
931
|
+
.ui-shell .ui-field {
|
|
932
|
+
gap: 6px;
|
|
933
|
+
}
|
|
934
|
+
.ui-shell .ui-input {
|
|
935
|
+
min-height: 40px;
|
|
936
|
+
padding: 8px 12px;
|
|
937
|
+
font-size: var(--text-sm);
|
|
938
|
+
border-radius: var(--radius-sm);
|
|
939
|
+
}
|
|
940
|
+
.ui-shell .ui-input:focus {
|
|
941
|
+
border-color: var(--color-ink);
|
|
942
|
+
box-shadow: 0 0 0 3px var(--color-surface-2);
|
|
943
|
+
}
|
|
856
944
|
}
|
|
857
945
|
|
|
858
946
|
/* Data table: hairline rows, numbers right-aligned in tabular figures (`.num`). */
|
|
@@ -892,6 +980,8 @@ body.ui-sheet-open { overflow: hidden; }
|
|
|
892
980
|
@media (prefers-reduced-motion: reduce) {
|
|
893
981
|
.ui-sheet,
|
|
894
982
|
.ui-sheet-backdrop,
|
|
983
|
+
.ui-sheet-backdrop.leaving,
|
|
984
|
+
.ui-sheet-backdrop.leaving .ui-sheet,
|
|
895
985
|
.ui-pill.pulse i,
|
|
896
986
|
.ui-skeleton span {
|
|
897
987
|
animation: none;
|
package/dist/components.css
CHANGED
|
@@ -35,10 +35,10 @@
|
|
|
35
35
|
max-width: var(--page-max);
|
|
36
36
|
margin: 0 auto;
|
|
37
37
|
padding:
|
|
38
|
-
calc(var(--space-4) + var(--ambasada-
|
|
39
|
-
calc(var(--space-4) + var(--ambasada-
|
|
40
|
-
calc(var(--space-8) + var(--ambasada-
|
|
41
|
-
calc(var(--space-4) + var(--ambasada-
|
|
38
|
+
calc(var(--space-4) + var(--ambasada-inset-top) + var(--ambasada-host-bar, 0px))
|
|
39
|
+
calc(var(--space-4) + var(--ambasada-inset-right))
|
|
40
|
+
calc(var(--space-8) + var(--ambasada-inset-bottom))
|
|
41
|
+
calc(var(--space-4) + var(--ambasada-inset-left));
|
|
42
42
|
display: flex;
|
|
43
43
|
flex-direction: column;
|
|
44
44
|
gap: var(--space-4);
|
|
@@ -275,73 +275,110 @@
|
|
|
275
275
|
}
|
|
276
276
|
|
|
277
277
|
/* ── bottom sheet ───────────────────────────────────────────────────────────
|
|
278
|
-
*
|
|
278
|
+
* Anything that opens over a page is a bottom sheet, at every width: never a centred dialog (docs/MINI-APPS.md
|
|
279
|
+
* §15, decision 12). A grip and a head you can drag down, a body that scrolls, a foot with the one primary
|
|
280
|
+
* action. The React Sheet portals it to <body> (so no transformed ancestor can trap it) and sizes the backdrop
|
|
281
|
+
* to the visual viewport (--ui-vv-top, --ui-vv-height, --ui-keyboard), so the foot stays above the keyboard. */
|
|
279
282
|
|
|
280
283
|
body.ui-sheet-open { overflow: hidden; }
|
|
281
284
|
.ui-sheet-backdrop {
|
|
282
285
|
position: fixed;
|
|
283
|
-
|
|
286
|
+
left: 0;
|
|
287
|
+
right: 0;
|
|
288
|
+
top: var(--ui-vv-top, 0px);
|
|
289
|
+
height: var(--ui-vv-height, 100%);
|
|
284
290
|
z-index: 50;
|
|
285
|
-
background: var(--color-backdrop);
|
|
286
291
|
display: flex;
|
|
287
|
-
|
|
288
|
-
justify-content:
|
|
292
|
+
flex-direction: column;
|
|
293
|
+
justify-content: flex-end;
|
|
294
|
+
align-items: center;
|
|
295
|
+
padding-top: calc(var(--ambasada-inset-top) + var(--ambasada-host-bar, 0px) + var(--space-2));
|
|
296
|
+
background: var(--color-backdrop);
|
|
289
297
|
animation: ui-fade var(--duration) var(--ease);
|
|
290
298
|
}
|
|
291
299
|
.ui-sheet {
|
|
292
300
|
width: 100%;
|
|
293
301
|
max-width: var(--page-max);
|
|
294
|
-
max-height:
|
|
302
|
+
max-height: 100%;
|
|
303
|
+
min-height: 0;
|
|
295
304
|
display: flex;
|
|
296
305
|
flex-direction: column;
|
|
297
306
|
background: var(--color-bg);
|
|
298
307
|
border-radius: var(--radius-hero) var(--radius-hero) 0 0;
|
|
299
308
|
box-shadow: var(--shadow-raised);
|
|
300
|
-
animation: ui-rise
|
|
301
|
-
|
|
309
|
+
animation: ui-rise 320ms var(--ease);
|
|
310
|
+
/* The home indicator stays clear; with the keyboard up it is covered anyway, so the inset goes. */
|
|
311
|
+
padding-bottom: max(0px, calc(var(--ambasada-inset-bottom) - var(--ui-keyboard, 0px)));
|
|
312
|
+
padding-left: var(--ambasada-inset-left);
|
|
313
|
+
padding-right: var(--ambasada-inset-right);
|
|
314
|
+
will-change: transform;
|
|
315
|
+
}
|
|
316
|
+
.ui-sheet-backdrop.leaving { animation: ui-fade-out 200ms var(--ease) both; }
|
|
317
|
+
.ui-sheet-backdrop.leaving .ui-sheet { animation: ui-sink 200ms var(--ease) both; }
|
|
318
|
+
/* Grip and head: the drag handle. touch-action none so a drag moves the sheet, not the page. */
|
|
319
|
+
.ui-sheet-handle {
|
|
320
|
+
flex: none;
|
|
321
|
+
touch-action: none;
|
|
322
|
+
cursor: grab;
|
|
323
|
+
-webkit-user-select: none;
|
|
324
|
+
user-select: none;
|
|
302
325
|
}
|
|
303
326
|
.ui-sheet-grip {
|
|
304
327
|
width: 36px;
|
|
305
|
-
height:
|
|
306
|
-
margin:
|
|
307
|
-
border-radius:
|
|
328
|
+
height: 5px;
|
|
329
|
+
margin: var(--space-2) auto 0;
|
|
330
|
+
border-radius: 3px;
|
|
308
331
|
background: var(--color-line-strong);
|
|
309
332
|
}
|
|
310
333
|
.ui-sheet-head {
|
|
311
334
|
display: flex;
|
|
312
335
|
align-items: center;
|
|
313
336
|
justify-content: space-between;
|
|
314
|
-
|
|
337
|
+
gap: var(--space-3);
|
|
338
|
+
min-height: 52px;
|
|
339
|
+
padding: var(--space-1) var(--space-3) var(--space-1) var(--space-5);
|
|
315
340
|
}
|
|
316
341
|
.ui-sheet-head h2 {
|
|
342
|
+
flex: 1;
|
|
343
|
+
min-width: 0;
|
|
317
344
|
margin: 0;
|
|
318
345
|
font-size: var(--text-xl);
|
|
346
|
+
font-weight: var(--weight-semibold);
|
|
347
|
+
letter-spacing: var(--tracking-tight);
|
|
348
|
+
line-height: 1.2;
|
|
349
|
+
overflow-wrap: anywhere;
|
|
319
350
|
}
|
|
351
|
+
.ui-sheet-head .ui-icon-btn { flex: none; }
|
|
320
352
|
.ui-sheet-body {
|
|
353
|
+
flex: 1 1 auto;
|
|
354
|
+
min-height: 0;
|
|
321
355
|
overflow-y: auto;
|
|
322
|
-
|
|
356
|
+
overscroll-behavior: contain;
|
|
357
|
+
-webkit-overflow-scrolling: touch;
|
|
358
|
+
padding: var(--space-3) var(--space-5) var(--space-5);
|
|
323
359
|
display: flex;
|
|
324
360
|
flex-direction: column;
|
|
325
|
-
gap: var(--space-
|
|
361
|
+
gap: var(--space-5);
|
|
326
362
|
}
|
|
363
|
+
.ui-sheet-body > * { flex: none; }
|
|
327
364
|
.ui-sheet-foot {
|
|
328
|
-
|
|
365
|
+
flex: none;
|
|
366
|
+
padding: var(--space-3) var(--space-5);
|
|
329
367
|
border-top: 1px solid var(--color-line);
|
|
330
368
|
display: flex;
|
|
331
369
|
gap: var(--space-2);
|
|
332
370
|
}
|
|
333
371
|
.ui-sheet-foot > button,
|
|
334
|
-
.ui-sheet-foot >
|
|
335
|
-
|
|
372
|
+
.ui-sheet-foot > a,
|
|
373
|
+
.ui-sheet-foot > .ui-button { flex: 1; min-height: 48px; }
|
|
374
|
+
@keyframes ui-rise { from { transform: translate3d(0, 100%, 0); } to { transform: none; } }
|
|
375
|
+
@keyframes ui-sink { from { transform: none; } to { transform: translate3d(0, 100%, 0); } }
|
|
336
376
|
@keyframes ui-fade { from { opacity: 0; } to { opacity: 1; } }
|
|
337
|
-
@
|
|
338
|
-
.ui-sheet-backdrop { align-items: center; padding: var(--space-6); }
|
|
339
|
-
.ui-sheet { border-radius: var(--radius-hero); max-height: 86dvh; }
|
|
340
|
-
.ui-sheet-grip { display: none; }
|
|
341
|
-
}
|
|
377
|
+
@keyframes ui-fade-out { from { opacity: 1; } to { opacity: 0; } }
|
|
342
378
|
|
|
343
|
-
/* Round icon button (the sheet's close). */
|
|
379
|
+
/* Round icon button (the sheet's close): a 36px circle in a 44px target. */
|
|
344
380
|
.ui-icon-btn {
|
|
381
|
+
position: relative;
|
|
345
382
|
width: var(--tap);
|
|
346
383
|
height: var(--tap);
|
|
347
384
|
min-height: 0;
|
|
@@ -349,12 +386,77 @@ body.ui-sheet-open { overflow: hidden; }
|
|
|
349
386
|
display: grid;
|
|
350
387
|
place-items: center;
|
|
351
388
|
border-radius: 50%;
|
|
352
|
-
background:
|
|
389
|
+
background: none;
|
|
353
390
|
color: var(--color-ink-2);
|
|
354
391
|
border: 0;
|
|
355
392
|
cursor: pointer;
|
|
393
|
+
isolation: isolate;
|
|
394
|
+
}
|
|
395
|
+
.ui-icon-btn::before {
|
|
396
|
+
content: "";
|
|
397
|
+
position: absolute;
|
|
398
|
+
inset: 4px;
|
|
399
|
+
z-index: -1;
|
|
400
|
+
border-radius: 50%;
|
|
401
|
+
background: var(--color-surface-2);
|
|
402
|
+
transition: background-color var(--duration) var(--ease);
|
|
356
403
|
}
|
|
357
404
|
.ui-icon-btn:hover { filter: none; color: var(--color-ink); }
|
|
405
|
+
.ui-icon-btn:hover::before { background: var(--color-line); }
|
|
406
|
+
|
|
407
|
+
/* ── fields ─────────────────────────────────────────────────────────────────
|
|
408
|
+
* A label, the control, one line of hint or error (.bad) under it, 8px apart. Inputs are 16px so iOS does not zoom into them. */
|
|
409
|
+
|
|
410
|
+
.ui-field {
|
|
411
|
+
display: flex;
|
|
412
|
+
flex-direction: column;
|
|
413
|
+
gap: var(--space-2);
|
|
414
|
+
min-width: 0;
|
|
415
|
+
}
|
|
416
|
+
.ui-field-label {
|
|
417
|
+
font-size: var(--text-sm);
|
|
418
|
+
font-weight: var(--weight-medium);
|
|
419
|
+
color: var(--color-ink);
|
|
420
|
+
line-height: 1.3;
|
|
421
|
+
}
|
|
422
|
+
.ui-field-hint {
|
|
423
|
+
font-size: var(--text-xs);
|
|
424
|
+
color: var(--color-ink-3);
|
|
425
|
+
line-height: 1.45;
|
|
426
|
+
}
|
|
427
|
+
.ui-field-hint.bad { color: var(--color-danger); }
|
|
428
|
+
.ui-field-hint.good { color: var(--color-success); }
|
|
429
|
+
.ui-input {
|
|
430
|
+
display: block;
|
|
431
|
+
width: 100%;
|
|
432
|
+
min-width: 0;
|
|
433
|
+
min-height: 48px;
|
|
434
|
+
margin: 0;
|
|
435
|
+
padding: 12px 14px;
|
|
436
|
+
font: inherit;
|
|
437
|
+
font-family: var(--font-sans);
|
|
438
|
+
font-size: var(--text-base);
|
|
439
|
+
letter-spacing: normal;
|
|
440
|
+
line-height: 1.4;
|
|
441
|
+
color: var(--color-ink);
|
|
442
|
+
background: var(--color-surface);
|
|
443
|
+
border: 1px solid var(--color-line);
|
|
444
|
+
border-radius: var(--radius-md);
|
|
445
|
+
-webkit-appearance: none;
|
|
446
|
+
appearance: none;
|
|
447
|
+
transition: border-color var(--duration) var(--ease), box-shadow var(--duration) var(--ease);
|
|
448
|
+
}
|
|
449
|
+
.ui-input::placeholder { color: var(--color-ink-3); opacity: 1; }
|
|
450
|
+
.ui-input:hover { border-color: var(--color-line-strong); }
|
|
451
|
+
.ui-input:focus {
|
|
452
|
+
outline: none;
|
|
453
|
+
border-color: var(--color-accent);
|
|
454
|
+
box-shadow: 0 0 0 3px var(--color-accent-soft);
|
|
455
|
+
}
|
|
456
|
+
.ui-input:disabled { background: var(--color-surface-2); color: var(--color-ink-2); border-color: var(--color-line); }
|
|
457
|
+
.ui-input.mono { font-family: var(--font-mono); letter-spacing: 0.02em; }
|
|
458
|
+
.ui-input.code { font-family: var(--font-mono); letter-spacing: 0.12em; text-transform: uppercase; }
|
|
459
|
+
textarea.ui-input { resize: vertical; min-height: 104px; }
|
|
358
460
|
|
|
359
461
|
/* ── stepper ────────────────────────────────────────────────────────────────
|
|
360
462
|
* − value + for amounts, with optional preset chips underneath. */
|
|
@@ -364,19 +466,20 @@ body.ui-sheet-open { overflow: hidden; }
|
|
|
364
466
|
display: flex;
|
|
365
467
|
align-items: center;
|
|
366
468
|
justify-content: space-between;
|
|
469
|
+
min-height: 48px;
|
|
367
470
|
border: 1px solid var(--color-line);
|
|
368
471
|
border-radius: var(--radius-md);
|
|
369
472
|
background: var(--color-surface);
|
|
370
|
-
padding:
|
|
473
|
+
padding: 3px;
|
|
371
474
|
}
|
|
372
475
|
.ui-stepper button {
|
|
373
|
-
width:
|
|
374
|
-
height:
|
|
476
|
+
width: 52px;
|
|
477
|
+
height: 40px;
|
|
375
478
|
min-height: 0;
|
|
376
479
|
padding: 0;
|
|
377
480
|
display: grid;
|
|
378
481
|
place-items: center;
|
|
379
|
-
border-radius:
|
|
482
|
+
border-radius: var(--radius-sm);
|
|
380
483
|
background: var(--color-surface-2);
|
|
381
484
|
color: var(--color-ink);
|
|
382
485
|
border: 0;
|
|
@@ -390,10 +493,11 @@ body.ui-sheet-open { overflow: hidden; }
|
|
|
390
493
|
font-variant-numeric: tabular-nums;
|
|
391
494
|
}
|
|
392
495
|
.ui-stepper-value small { font-size: var(--text-xs); color: var(--color-ink-3); font-weight: var(--weight-regular); }
|
|
393
|
-
.ui-presets { display: flex; gap:
|
|
496
|
+
.ui-presets { display: flex; gap: var(--space-2); flex-wrap: wrap; }
|
|
394
497
|
.ui-presets button {
|
|
395
|
-
min-
|
|
396
|
-
|
|
498
|
+
min-width: 48px;
|
|
499
|
+
min-height: 36px;
|
|
500
|
+
padding: 4px 14px;
|
|
397
501
|
border-radius: var(--radius-pill);
|
|
398
502
|
background: var(--color-surface);
|
|
399
503
|
color: var(--color-ink-2);
|
|
@@ -648,48 +752,21 @@ body.ui-sheet-open { overflow: hidden; }
|
|
|
648
752
|
border-bottom-color: var(--color-ink);
|
|
649
753
|
}
|
|
650
754
|
|
|
651
|
-
/*
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
color: var(--color-danger);
|
|
667
|
-
}
|
|
668
|
-
.ui-field-hint.good {
|
|
669
|
-
color: var(--color-success);
|
|
670
|
-
}
|
|
671
|
-
.ui-input {
|
|
672
|
-
width: 100%;
|
|
673
|
-
min-height: 40px;
|
|
674
|
-
padding: 8px 12px;
|
|
675
|
-
border: 1px solid var(--color-line);
|
|
676
|
-
border-radius: var(--radius-sm);
|
|
677
|
-
background: var(--color-surface);
|
|
678
|
-
color: var(--color-ink);
|
|
679
|
-
font: inherit;
|
|
680
|
-
font-size: var(--text-sm);
|
|
681
|
-
}
|
|
682
|
-
.ui-input:hover {
|
|
683
|
-
border-color: var(--color-line-strong);
|
|
684
|
-
}
|
|
685
|
-
.ui-input:focus {
|
|
686
|
-
outline: none;
|
|
687
|
-
border-color: var(--color-ink);
|
|
688
|
-
box-shadow: 0 0 0 3px var(--color-surface-2);
|
|
689
|
-
}
|
|
690
|
-
.ui-input:disabled {
|
|
691
|
-
background: var(--color-surface-2);
|
|
692
|
-
color: var(--color-ink-3);
|
|
755
|
+
/* Fields inside a desktop tool are compact with a mouse. On a phone they stay 48 px and 16 px, so iOS does not zoom. */
|
|
756
|
+
@media (pointer: fine) {
|
|
757
|
+
.ui-shell .ui-field {
|
|
758
|
+
gap: 6px;
|
|
759
|
+
}
|
|
760
|
+
.ui-shell .ui-input {
|
|
761
|
+
min-height: 40px;
|
|
762
|
+
padding: 8px 12px;
|
|
763
|
+
font-size: var(--text-sm);
|
|
764
|
+
border-radius: var(--radius-sm);
|
|
765
|
+
}
|
|
766
|
+
.ui-shell .ui-input:focus {
|
|
767
|
+
border-color: var(--color-ink);
|
|
768
|
+
box-shadow: 0 0 0 3px var(--color-surface-2);
|
|
769
|
+
}
|
|
693
770
|
}
|
|
694
771
|
|
|
695
772
|
/* Data table: hairline rows, numbers right-aligned in tabular figures (`.num`). */
|
|
@@ -729,6 +806,8 @@ body.ui-sheet-open { overflow: hidden; }
|
|
|
729
806
|
@media (prefers-reduced-motion: reduce) {
|
|
730
807
|
.ui-sheet,
|
|
731
808
|
.ui-sheet-backdrop,
|
|
809
|
+
.ui-sheet-backdrop.leaving,
|
|
810
|
+
.ui-sheet-backdrop.leaving .ui-sheet,
|
|
732
811
|
.ui-pill.pulse i,
|
|
733
812
|
.ui-skeleton span {
|
|
734
813
|
animation: none;
|
package/dist/react.d.ts
CHANGED
|
@@ -38,7 +38,12 @@ export declare function Pill({ tone, children, pulse }: {
|
|
|
38
38
|
children: ReactNode;
|
|
39
39
|
pulse?: boolean;
|
|
40
40
|
}): import("react").JSX.Element;
|
|
41
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Bottom sheet, at every width (never a centred dialog). Closes on a tap on the dim, the close button, Escape or a
|
|
43
|
+
* drag down on the grip and title, sliding out first; the page behind does not scroll. Rendered into <body>, so a
|
|
44
|
+
* transformed or clipped ancestor cannot trap it, and fitted to the visual viewport, so the foot (put the one
|
|
45
|
+
* primary action there) stays above the on-screen keyboard on iOS and Android.
|
|
46
|
+
*/
|
|
42
47
|
export declare function Sheet({ open, onClose, title, children, footer }: {
|
|
43
48
|
open: boolean;
|
|
44
49
|
onClose: () => void;
|
|
@@ -46,6 +51,19 @@ export declare function Sheet({ open, onClose, title, children, footer }: {
|
|
|
46
51
|
children: ReactNode;
|
|
47
52
|
footer?: ReactNode;
|
|
48
53
|
}): import("react").JSX.Element | null;
|
|
54
|
+
export type FieldProps = InputHTMLAttributes<HTMLInputElement> & {
|
|
55
|
+
label?: ReactNode;
|
|
56
|
+
hint?: ReactNode;
|
|
57
|
+
error?: ReactNode;
|
|
58
|
+
tone?: "good" | "bad";
|
|
59
|
+
htmlFor?: string;
|
|
60
|
+
children?: ReactNode;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Label, control, one line of hint (or `error`, which wins) under it. Without children it is the input itself (the
|
|
64
|
+
* other props go to the <input>); with children it wraps them: a textarea, a Stepper, … (give inputs className="ui-input").
|
|
65
|
+
*/
|
|
66
|
+
export declare function Field({ label, hint, error, tone, htmlFor, children, className, ...input }: FieldProps): import("react").JSX.Element;
|
|
49
67
|
export type StepperProps = {
|
|
50
68
|
value: number;
|
|
51
69
|
onChange: (v: number) => void;
|
|
@@ -106,10 +124,3 @@ export declare function Tabs({ tabs, current, onClick, label }: {
|
|
|
106
124
|
onClick?: MouseEventHandler<HTMLAnchorElement>;
|
|
107
125
|
label?: string;
|
|
108
126
|
}): import("react").JSX.Element;
|
|
109
|
-
/** Label, input, one line of hint (or `error`, which wins) under it. */
|
|
110
|
-
export declare function Field({ label, hint, error, tone, className, ...input }: InputHTMLAttributes<HTMLInputElement> & {
|
|
111
|
-
label: ReactNode;
|
|
112
|
-
hint?: ReactNode;
|
|
113
|
-
error?: ReactNode;
|
|
114
|
-
tone?: "good" | "bad";
|
|
115
|
-
}): import("react").JSX.Element;
|
package/dist/react.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
// ../ui/src/react.tsx
|
|
4
|
-
import { useEffect, useId } from "react";
|
|
4
|
+
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
|
5
|
+
import { createPortal } from "react-dom";
|
|
5
6
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
6
7
|
var cx = (...names) => names.filter(Boolean).join(" ");
|
|
7
8
|
var fmt = (v) => v.toLocaleString("en-US", { maximumFractionDigits: 2 });
|
|
@@ -49,28 +50,103 @@ function Pill({ tone = "muted", children, pulse }) {
|
|
|
49
50
|
children
|
|
50
51
|
] });
|
|
51
52
|
}
|
|
53
|
+
var reducedMotion = () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
54
|
+
var OUT_MS = 200;
|
|
52
55
|
function Sheet({ open, onClose, title, children, footer }) {
|
|
53
56
|
const id = useId();
|
|
57
|
+
const backdrop = useRef(null);
|
|
58
|
+
const sheet = useRef(null);
|
|
59
|
+
const drag = useRef(null);
|
|
60
|
+
const close = useRef(onClose);
|
|
61
|
+
close.current = onClose;
|
|
62
|
+
const [leaving, setLeaving] = useState(false);
|
|
63
|
+
const dismiss = useCallback(() => reducedMotion() ? close.current() : setLeaving(true), []);
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!leaving) return;
|
|
66
|
+
const t = setTimeout(() => {
|
|
67
|
+
setLeaving(false);
|
|
68
|
+
close.current();
|
|
69
|
+
}, OUT_MS);
|
|
70
|
+
return () => clearTimeout(t);
|
|
71
|
+
}, [leaving]);
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!open) setLeaving(false);
|
|
74
|
+
}, [open]);
|
|
54
75
|
useEffect(() => {
|
|
55
76
|
if (!open) return;
|
|
56
|
-
const onKey = (e) => e.key === "Escape" &&
|
|
77
|
+
const onKey = (e) => e.key === "Escape" && dismiss();
|
|
57
78
|
document.addEventListener("keydown", onKey);
|
|
58
79
|
document.body.classList.add("ui-sheet-open");
|
|
80
|
+
const vv = window.visualViewport;
|
|
81
|
+
const fit = () => {
|
|
82
|
+
const el = backdrop.current;
|
|
83
|
+
if (!vv || !el) return;
|
|
84
|
+
const keyboard = Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop));
|
|
85
|
+
el.style.setProperty("--ui-vv-top", `${vv.offsetTop}px`);
|
|
86
|
+
el.style.setProperty("--ui-vv-height", `${vv.height}px`);
|
|
87
|
+
el.style.setProperty("--ui-keyboard", `${keyboard}px`);
|
|
88
|
+
const focused = document.activeElement;
|
|
89
|
+
if (keyboard > 0 && focused instanceof HTMLElement && sheet.current?.contains(focused)) focused.scrollIntoView({ block: "nearest" });
|
|
90
|
+
};
|
|
91
|
+
fit();
|
|
92
|
+
vv?.addEventListener("resize", fit);
|
|
93
|
+
vv?.addEventListener("scroll", fit);
|
|
59
94
|
return () => {
|
|
60
95
|
document.removeEventListener("keydown", onKey);
|
|
61
96
|
document.body.classList.remove("ui-sheet-open");
|
|
97
|
+
vv?.removeEventListener("resize", fit);
|
|
98
|
+
vv?.removeEventListener("scroll", fit);
|
|
62
99
|
};
|
|
63
|
-
}, [open,
|
|
100
|
+
}, [open, dismiss]);
|
|
101
|
+
const onDown = (e) => {
|
|
102
|
+
if (e.button !== 0 || e.target.closest("button, a, input, textarea, select")) return;
|
|
103
|
+
drag.current = { y: e.clientY, t: e.timeStamp, dy: 0 };
|
|
104
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
105
|
+
if (sheet.current) sheet.current.style.transition = "none";
|
|
106
|
+
};
|
|
107
|
+
const onMove = (e) => {
|
|
108
|
+
const d = drag.current;
|
|
109
|
+
if (!d || !sheet.current) return;
|
|
110
|
+
d.dy = Math.max(0, e.clientY - d.y);
|
|
111
|
+
sheet.current.style.transform = d.dy ? `translate3d(0, ${d.dy}px, 0)` : "";
|
|
112
|
+
};
|
|
113
|
+
const onUp = (e) => {
|
|
114
|
+
const d = drag.current;
|
|
115
|
+
const el = sheet.current;
|
|
116
|
+
drag.current = null;
|
|
117
|
+
if (!d || !el) return;
|
|
118
|
+
const flick = d.dy > 24 && d.dy / Math.max(1, e.timeStamp - d.t) > 0.5;
|
|
119
|
+
el.style.transition = `transform ${OUT_MS}ms cubic-bezier(0.2, 0, 0, 1)`;
|
|
120
|
+
if (flick || d.dy > Math.min(160, el.offsetHeight / 3)) {
|
|
121
|
+
el.style.transform = "translate3d(0, 100%, 0)";
|
|
122
|
+
if (backdrop.current) Object.assign(backdrop.current.style, { transition: `opacity ${OUT_MS}ms`, opacity: "0" });
|
|
123
|
+
setTimeout(() => close.current(), OUT_MS);
|
|
124
|
+
} else el.style.transform = "";
|
|
125
|
+
};
|
|
64
126
|
if (!open) return null;
|
|
65
|
-
|
|
66
|
-
/* @__PURE__ */
|
|
67
|
-
|
|
68
|
-
/* @__PURE__ */
|
|
69
|
-
|
|
127
|
+
const node = /* @__PURE__ */ jsx("div", { ref: backdrop, className: cx("ui-sheet-backdrop", leaving && "leaving"), onClick: dismiss, children: /* @__PURE__ */ jsxs("div", { ref: sheet, className: "ui-sheet", role: "dialog", "aria-modal": "true", "aria-labelledby": title ? id : void 0, onClick: (e) => e.stopPropagation(), children: [
|
|
128
|
+
/* @__PURE__ */ jsxs("div", { className: "ui-sheet-handle", onPointerDown: onDown, onPointerMove: onMove, onPointerUp: onUp, onPointerCancel: onUp, children: [
|
|
129
|
+
/* @__PURE__ */ jsx("div", { className: "ui-sheet-grip" }),
|
|
130
|
+
title && /* @__PURE__ */ jsxs("div", { className: "ui-sheet-head", children: [
|
|
131
|
+
/* @__PURE__ */ jsx("h2", { id, children: title }),
|
|
132
|
+
/* @__PURE__ */ jsx("button", { type: "button", className: "ui-icon-btn", "aria-label": "Close", onClick: dismiss, children: /* @__PURE__ */ jsx(Icon, { d: "M6 6l12 12M18 6 6 18", size: 20 }) })
|
|
133
|
+
] })
|
|
70
134
|
] }),
|
|
71
135
|
/* @__PURE__ */ jsx("div", { className: "ui-sheet-body", children }),
|
|
72
136
|
footer && /* @__PURE__ */ jsx("div", { className: "ui-sheet-foot", children: footer })
|
|
73
137
|
] }) });
|
|
138
|
+
return typeof document === "undefined" ? node : createPortal(node, document.body);
|
|
139
|
+
}
|
|
140
|
+
function Field({ label, hint, error, tone, htmlFor, children, className, ...input }) {
|
|
141
|
+
const auto = useId();
|
|
142
|
+
const id = children === void 0 ? input.id ?? auto : htmlFor;
|
|
143
|
+
const note = error ?? hint;
|
|
144
|
+
const noteId = id && `${id}-hint`;
|
|
145
|
+
return /* @__PURE__ */ jsxs("div", { className: "ui-field", children: [
|
|
146
|
+
label && (id ? /* @__PURE__ */ jsx("label", { className: "ui-field-label", htmlFor: id, children: label }) : /* @__PURE__ */ jsx("span", { className: "ui-field-label", children: label })),
|
|
147
|
+
children ?? /* @__PURE__ */ jsx("input", { ...input, id, className: cx("ui-input", className), "aria-invalid": error ? true : void 0, "aria-describedby": note ? noteId : void 0 }),
|
|
148
|
+
note && /* @__PURE__ */ jsx("span", { id: noteId, className: cx("ui-field-hint", error ? "bad" : tone), role: error ? "alert" : void 0, children: note })
|
|
149
|
+
] });
|
|
74
150
|
}
|
|
75
151
|
function Stepper({ value, onChange, min = 0, max = 1e5, step = 1, unit, presets, format = fmt }) {
|
|
76
152
|
const clamp = (v) => Math.min(max, Math.max(min, Math.round(v * 100) / 100));
|
|
@@ -138,15 +214,6 @@ function NavItem({ icon, label, right, href, onClick, current }) {
|
|
|
138
214
|
function Tabs({ tabs, current, onClick, label }) {
|
|
139
215
|
return /* @__PURE__ */ jsx("nav", { className: "ui-tabs", "aria-label": label, children: tabs.map((t) => /* @__PURE__ */ jsx("a", { href: t.href, onClick, className: t.id === current ? "on" : void 0, "aria-current": t.id === current ? "page" : void 0, children: t.label }, t.id)) });
|
|
140
216
|
}
|
|
141
|
-
function Field({ label, hint, error, tone, className, ...input }) {
|
|
142
|
-
const id = useId();
|
|
143
|
-
const note = error ?? hint;
|
|
144
|
-
return /* @__PURE__ */ jsxs("div", { className: "ui-field", children: [
|
|
145
|
-
/* @__PURE__ */ jsx("label", { className: "ui-field-label", htmlFor: input.id ?? id, children: label }),
|
|
146
|
-
/* @__PURE__ */ jsx("input", { id, ...input, className: cx("ui-input", className), "aria-invalid": error ? true : void 0, "aria-describedby": note ? `${input.id ?? id}-hint` : void 0 }),
|
|
147
|
-
note && /* @__PURE__ */ jsx("span", { id: `${input.id ?? id}-hint`, className: cx("ui-field-hint", error ? "bad" : tone), children: note })
|
|
148
|
-
] });
|
|
149
|
-
}
|
|
150
217
|
export {
|
|
151
218
|
Button,
|
|
152
219
|
Card,
|
package/dist/tokens.css
CHANGED
|
@@ -117,5 +117,16 @@
|
|
|
117
117
|
--ambasada-safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
|
118
118
|
--ambasada-safe-area-left: env(safe-area-inset-left, 0px);
|
|
119
119
|
--ambasada-safe-area-right: env(safe-area-inset-right, 0px);
|
|
120
|
-
--ambasada-host-bar: 0px; /* height of the shell's top bar (
|
|
120
|
+
--ambasada-host-bar: 0px; /* height of the shell's top bar (close, app name, "…" menu) */
|
|
121
|
+
|
|
122
|
+
/*
|
|
123
|
+
* What a page actually keeps clear, per side: the larger of the shell's report and the browser's own inset.
|
|
124
|
+
* A shell that reports 0 while its WebView runs under the home indicator, and a browser that knows nothing of
|
|
125
|
+
* the shell, both come out right, and an inset is never counted twice (docs/MINI-APPS.md §15, decision 11).
|
|
126
|
+
* Use these (plus --ambasada-host-bar at the top), never env() or the raw --ambasada-safe-area-* directly.
|
|
127
|
+
*/
|
|
128
|
+
--ambasada-inset-top: max(var(--ambasada-safe-area-top), env(safe-area-inset-top, 0px));
|
|
129
|
+
--ambasada-inset-bottom: max(var(--ambasada-safe-area-bottom), env(safe-area-inset-bottom, 0px));
|
|
130
|
+
--ambasada-inset-left: max(var(--ambasada-safe-area-left), env(safe-area-inset-left, 0px));
|
|
131
|
+
--ambasada-inset-right: max(var(--ambasada-safe-area-right), env(safe-area-inset-right, 0px));
|
|
121
132
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ambasada/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "The Ambasada look for mini apps: design tokens (colours, Inter + JetBrains Mono, radii, spacing, safe areas) and a small component set in plain CSS, with optional React wrappers. One stylesheet to <link>.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,11 +27,15 @@
|
|
|
27
27
|
"LICENSE"
|
|
28
28
|
],
|
|
29
29
|
"peerDependencies": {
|
|
30
|
-
"react": ">=18"
|
|
30
|
+
"react": ">=18",
|
|
31
|
+
"react-dom": ">=18"
|
|
31
32
|
},
|
|
32
33
|
"peerDependenciesMeta": {
|
|
33
34
|
"react": {
|
|
34
35
|
"optional": true
|
|
36
|
+
},
|
|
37
|
+
"react-dom": {
|
|
38
|
+
"optional": true
|
|
35
39
|
}
|
|
36
40
|
},
|
|
37
41
|
"devDependencies": {
|