@0001-labs/design-system-1 0.3.0-alpha.18 → 0.3.0-alpha.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/DS1/0-face/app-banner.ts +148 -0
  2. package/DS1/1-root/cjk.css +1 -1
  3. package/DS1/1-root/context.css +7 -6
  4. package/DS1/1-root/fonts.css +1 -1
  5. package/DS1/1-root/notice.css +81 -0
  6. package/DS1/1-root/one.css +1 -1
  7. package/DS1/1-root/one.semantic.css +1 -1
  8. package/DS1/1-root/row.css +10 -1
  9. package/DS1/1-root/table.css +77 -0
  10. package/DS1/2-core/ds-message.ts +75 -4
  11. package/DS1/2-core/styles/ds-notice.css +13 -0
  12. package/DS1/3-unit/ds-context.ts +2 -2
  13. package/DS1/3-unit/ds-table.ts +35 -1
  14. package/DS1/3-unit/styles/ds-context.css +14 -8
  15. package/DS1/react/message.ts +16 -3
  16. package/DS1/react/page.ts +3 -2
  17. package/dist/0-face/app-banner.d.ts +60 -0
  18. package/dist/0-face/app-banner.d.ts.map +1 -0
  19. package/dist/0-face/app-banner.js +138 -0
  20. package/dist/2-core/ds-message.d.ts +49 -3
  21. package/dist/2-core/ds-message.d.ts.map +1 -1
  22. package/dist/2-core/ds-message.js +71 -3
  23. package/dist/3-unit/ds-context.d.ts +2 -2
  24. package/dist/3-unit/ds-context.js +2 -2
  25. package/dist/3-unit/ds-table.d.ts +10 -0
  26. package/dist/3-unit/ds-table.d.ts.map +1 -1
  27. package/dist/3-unit/ds-table.js +31 -1
  28. package/dist/design-system-1.bundle.js +206 -9
  29. package/dist/design-system-1.bundle.js.map +1 -1
  30. package/dist/design-system-1.bundle.min.js +2122 -1987
  31. package/dist/design-system-1.bundle.min.js.map +1 -1
  32. package/dist/react/message.d.ts +26 -2
  33. package/dist/react/message.d.ts.map +1 -1
  34. package/dist/react/message.js +3 -2
  35. package/dist/react/page.d.ts +4 -3
  36. package/dist/react/page.d.ts.map +1 -1
  37. package/dist/react/page.js +2 -1
  38. package/package.json +1 -1
@@ -0,0 +1,148 @@
1
+ // app-banner.ts
2
+ // Where a banner leading to a site's iPhone app shows, and what its Open does.
3
+ // `<ds-message form="app">` uses it, and a page without Lit calls
4
+ // `startAppBanners()` for every `.ds-notice--app` block it wrote.
5
+ //
6
+ // On a phone or tablet running iOS, Safari draws Apple's own Smart App Banner
7
+ // from `<meta name="apple-itunes-app">`, so the page draws nothing there; in
8
+ // another iOS browser (Chrome, Firefox, Edge…) the page draws Design system
9
+ // 1's banner. Never on a computer, an Android phone, a page opened from the
10
+ // home screen or an app's own web view, and never again once closed in this
11
+ // browser.
12
+
13
+ import { detectMobileDevice } from "./scaling";
14
+
15
+ export interface AppBannerOptions {
16
+ /** The app's App Store id, the digits after `id` in its store address. */
17
+ appId?: string;
18
+ /** The app's store address; `https://apps.apple.com/app/id<appId>` by default. */
19
+ appUrl?: string;
20
+ /** The address that opens the app at this page: a universal link or the app's own scheme. */
21
+ open?: string;
22
+ /** What remembers the close in this browser; one key per app by default. */
23
+ key?: string;
24
+ }
25
+
26
+ /** `safari`: Apple's banner from the meta tag; `drawn`: the page's own; `none`: nothing. */
27
+ export type AppBannerPlacement = "safari" | "drawn" | "none";
28
+
29
+ export interface AppBannerDevice {
30
+ userAgent: string;
31
+ touchPoints: number;
32
+ /** A phone or tablet, as the scale reads it. */
33
+ mobile: boolean;
34
+ /** Opened from the home screen. */
35
+ standalone: boolean;
36
+ }
37
+
38
+ /** iOS's other browsers, each with Safari's engine and its own token. */
39
+ const OTHER_BROWSERS = /CriOS|FxiOS|EdgiOS|OPiOS|OPT\/|GSA\/|DuckDuckGo|Ddg\/|YaBrowser|Focus\/|Brave\//;
40
+
41
+ export function appBannerDevice(): AppBannerDevice {
42
+ if (typeof navigator === "undefined" || typeof window === "undefined") return { userAgent: "", touchPoints: 0, mobile: false, standalone: false };
43
+ const standalone = Boolean((navigator as Navigator & { standalone?: boolean }).standalone)
44
+ || Boolean(window.matchMedia?.("(display-mode: standalone)").matches);
45
+ return { userAgent: navigator.userAgent || "", touchPoints: navigator.maxTouchPoints || 0, mobile: detectMobileDevice(), standalone };
46
+ }
47
+
48
+ /** An iPhone, iPod or iPad, including an iPad that asks for the desktop site. */
49
+ export function isAppleMobile({ userAgent, touchPoints }: Pick<AppBannerDevice, "userAgent" | "touchPoints">): boolean {
50
+ return /iPhone|iPad|iPod/.test(userAgent) || (/Macintosh/.test(userAgent) && touchPoints > 1);
51
+ }
52
+
53
+ /**
54
+ * Safari, another browser, or an app's web view. A web view's agent has no
55
+ * `Safari/` token (Safari's view controller and sign-in sheet keep it).
56
+ */
57
+ export function appleBrowser(userAgent: string): "safari" | "other" | "webview" {
58
+ if (OTHER_BROWSERS.test(userAgent)) return "other";
59
+ return /Safari\//.test(userAgent) ? "safari" : "webview";
60
+ }
61
+
62
+ export function appBannerKey(options: AppBannerOptions): string {
63
+ return options.key || `ds-app-banner:${options.appId || options.appUrl || options.open || ""}`;
64
+ }
65
+
66
+ export function appStoreUrl(options: AppBannerOptions): string {
67
+ return options.appUrl || (options.appId ? `https://apps.apple.com/app/id${options.appId}` : "");
68
+ }
69
+
70
+ export function isAppBannerClosed(options: AppBannerOptions): boolean {
71
+ try { return localStorage.getItem(appBannerKey(options)) === "closed"; } catch { return false; }
72
+ }
73
+
74
+ /** Remembers the close in this browser, so the banner never comes back here. */
75
+ export function closeAppBanner(options: AppBannerOptions): void {
76
+ try { localStorage.setItem(appBannerKey(options), "closed"); } catch { /* private mode keeps it for the page */ }
77
+ }
78
+
79
+ /**
80
+ * Where the banner shows on this device. Safari takes Apple's banner only
81
+ * when there is an App Store id for its meta tag; without one it draws the
82
+ * page's own, like the other browsers.
83
+ */
84
+ export function appBannerPlacement(options: AppBannerOptions, device: AppBannerDevice = appBannerDevice()): AppBannerPlacement {
85
+ if (!appStoreUrl(options) && !options.open) return "none";
86
+ if (!device.mobile || device.standalone || !isAppleMobile(device)) return "none";
87
+ const browser = appleBrowser(device.userAgent);
88
+ if (browser === "webview") return "none";
89
+ if (browser === "safari" && options.appId) return "safari";
90
+ return isAppBannerClosed(options) ? "none" : "drawn";
91
+ }
92
+
93
+ /**
94
+ * Writes Apple's `<meta name="apple-itunes-app">` when the page has none.
95
+ * Safari reads it as the page loads, so a page that knows its app writes the
96
+ * tag in its own head; this is the fallback for one that did not.
97
+ */
98
+ export function ensureSmartAppBanner(options: AppBannerOptions): void {
99
+ if (typeof document === "undefined" || !options.appId) return;
100
+ if (document.head.querySelector('meta[name="apple-itunes-app"]')) return;
101
+ const meta = document.createElement("meta");
102
+ meta.name = "apple-itunes-app";
103
+ meta.content = [`app-id=${options.appId}`, options.open ? `app-argument=${options.open}` : ""].filter(Boolean).join(", ");
104
+ document.head.append(meta);
105
+ }
106
+
107
+ /**
108
+ * Opens the app: the deep link first, then the App Store when the page is
109
+ * still in view a moment later (the app was not there to take it). Without a
110
+ * deep link, the store, which offers Open when the app is installed.
111
+ */
112
+ export function openApp(options: AppBannerOptions, wait = 1500): void {
113
+ const store = appStoreUrl(options);
114
+ if (!options.open) { if (store) location.href = store; return; }
115
+ location.href = options.open;
116
+ if (!store) return;
117
+ const timer = window.setTimeout(() => { if (document.visibilityState === "visible") location.href = store; }, wait);
118
+ const left = () => { window.clearTimeout(timer); document.removeEventListener("visibilitychange", left); window.removeEventListener("pagehide", left); };
119
+ document.addEventListener("visibilitychange", left);
120
+ window.addEventListener("pagehide", left);
121
+ }
122
+
123
+ /**
124
+ * Puts every `.ds-notice--app` block on the page in place: hides it unless
125
+ * this device draws it, writes the meta tag for Safari, and wires its Open
126
+ * link (`.ds-notice__open`) and close button (`.ds-notice__dismiss`). The
127
+ * block names its app in `data-app-id`, `data-app-url`, `data-open` and
128
+ * optionally `data-remember`. Returns a function that unwires them.
129
+ */
130
+ export function startAppBanners(root: ParentNode = document): () => void {
131
+ const undo: (() => void)[] = [];
132
+ for (const block of root.querySelectorAll<HTMLElement>(".ds-notice--app")) {
133
+ const options: AppBannerOptions = { appId: block.dataset.appId, appUrl: block.dataset.appUrl, open: block.dataset.open, key: block.dataset.remember };
134
+ const placement = appBannerPlacement(options);
135
+ if (placement === "safari") ensureSmartAppBanner(options);
136
+ block.hidden = placement !== "drawn";
137
+ if (block.hidden) continue;
138
+ const link = block.querySelector<HTMLAnchorElement>(".ds-notice__open");
139
+ const close = block.querySelector<HTMLButtonElement>(".ds-notice__dismiss");
140
+ if (link && !link.getAttribute("href")) link.href = appStoreUrl(options);
141
+ const onOpen = (event: MouseEvent) => { if (!options.open) return; event.preventDefault(); openApp(options); };
142
+ const onClose = () => { closeAppBanner(options); block.hidden = true; };
143
+ link?.addEventListener("click", onOpen);
144
+ close?.addEventListener("click", onClose);
145
+ undo.push(() => { link?.removeEventListener("click", onOpen); close?.removeEventListener("click", onClose); });
146
+ }
147
+ return () => undo.forEach(fn => fn());
148
+ }
@@ -1,4 +1,4 @@
1
- /* version 0.3.0-alpha.18 */
1
+ /* version 0.3.0-alpha.19 */
2
2
 
3
3
  /* Noto Sans for Japanese and Chinese, for a product that shows them. Opt in:
4
4
  link it beside one.css or fonts.css, or ask Sync 1 for "cjk": true. Without
@@ -4,9 +4,9 @@
4
4
  * `.ds-context` is the `ds-context` element's look on a native menu, for a page
5
5
  * without Lit or a menu a library draws: Ezo's gap face. The rows carry the
6
6
  * colour and the menu only cuts the outer silhouette, so the page shows
7
- * through a pixel between rows and two between groups. The ink is the rows'
8
- * fill and the paper their words, turned over with the theme as the tooltip
9
- * is. Every row is 19px with the pixel under it, a square's pitch, its words
7
+ * through a pixel between rows and two between groups. The rows are black
8
+ * with white words in both themes, as Ezo's menus always were (the slate in
9
+ * dark, darker than the page). Every row is 19px with the pixel under it, a square's pitch, its words
10
10
  * on the sixteen and an icon at its end. A Select's menu is the light face,
11
11
  * `.ds-menu`; a Context's is this dark one.
12
12
  *
@@ -28,10 +28,11 @@
28
28
 
29
29
  .ds-context {
30
30
  --ds-context-row: calc(var(--ds-row-height) - 1px);
31
- --ds-context-fill: light-dark(var(--ds-black, #1e1e1e), var(--ds-white, #ffffff));
32
- --ds-context-ink: light-dark(var(--ds-white, #ffffff), var(--ds-black, #1e1e1e));
31
+ /* Black in both themes, as Ezo's menus always were: in dark the slate, darker than the page. */
32
+ --ds-context-fill: light-dark(var(--ds-black, #2a2a2a), var(--ds-slate, #1e1e1e));
33
+ --ds-context-ink: var(--ds-white, #ffffff);
33
34
  --ds-context-hover: color-mix(in srgb, var(--ds-context-fill) 78%, var(--ds-context-ink));
34
- --ds-context-danger: light-dark(var(--ds-tuned-red), var(--ds-red-for-blood));
35
+ --ds-context-danger: var(--ds-tuned-red);
35
36
  --ds-context-refused: var(--ds-tuned-red);
36
37
  box-sizing: border-box;
37
38
  display: flex;
@@ -1,4 +1,4 @@
1
- /* version 0.3.0-alpha.18 */
1
+ /* version 0.3.0-alpha.19 */
2
2
 
3
3
  /* Design system 1's faces and the typeface tokens that name them, on their
4
4
  own: a page that does not load one.css links this sheet (one.css imports
@@ -21,6 +21,14 @@
21
21
  * ds-notice--banner a strip across the top of its positioning
22
22
  * context instead: one square high, filled in
23
23
  * the tone's colour, everything on one line
24
+ * ds-notice--app a row at the top of the page leading to the
25
+ * site's iPhone app: its picture
26
+ * (ds-notice__picture, two squares), its name
27
+ * and one line about it, Open
28
+ * (a.ds-notice__open) and the close; three
29
+ * squares high with a hairline at its foot.
30
+ * startAppBanners() from 0-face/app-banner.ts
31
+ * shows it only where it belongs
24
32
  *
25
33
  * Give the block role="status", or role="alert" for danger, so assistive
26
34
  * technology reads it when it appears.
@@ -163,3 +171,76 @@
163
171
  margin-block: 0;
164
172
  margin-inline-start: 0;
165
173
  }
174
+
175
+ /* The app banner: a row at the top of the page, in the flow, three squares
176
+ high: half a square above and below two squares of content, a hairline at
177
+ its foot. The app's picture fills two squares with a picture's corners; the
178
+ name and the line about it take a square each, on the sixteen; Open and the
179
+ close stand on the sixteen of the square in the middle of the two. */
180
+ .ds-notice--app {
181
+ grid-template-columns: calc(2 * var(--ds-unit)) minmax(0, 1fr) auto var(--ds-unit);
182
+ grid-template-rows: var(--ds-unit) var(--ds-unit);
183
+ column-gap: calc(var(--ds-unit) / 2);
184
+ align-items: end;
185
+ padding: calc(var(--ds-unit) / 2) var(--ds-unit);
186
+ background: var(--ds-paper);
187
+ box-shadow: inset 0 -1px 0 var(--ds-border-color);
188
+ line-height: var(--ds-type-lineheight-default);
189
+ }
190
+
191
+ .ds-notice--app[hidden] {
192
+ display: none;
193
+ }
194
+
195
+ .ds-notice__picture {
196
+ box-sizing: border-box;
197
+ grid-column: 1;
198
+ grid-row: 1 / 3;
199
+ align-self: stretch;
200
+ overflow: hidden;
201
+ border-radius: var(--ds-radius);
202
+ background: var(--ds-fill);
203
+ outline: 1px solid var(--ds-border-color);
204
+ outline-offset: -1px;
205
+ }
206
+
207
+ .ds-notice__picture img {
208
+ display: block;
209
+ width: 100%;
210
+ height: 100%;
211
+ object-fit: cover;
212
+ }
213
+
214
+ .ds-notice--app .ds-notice__title,
215
+ .ds-notice--app .ds-notice__text {
216
+ grid-column: 2;
217
+ white-space: nowrap;
218
+ overflow: hidden;
219
+ text-overflow: ellipsis;
220
+ }
221
+
222
+ .ds-notice--app .ds-notice__title { grid-row: 1; }
223
+
224
+ .ds-notice--app .ds-notice__text {
225
+ grid-row: 2;
226
+ color: var(--ds-text-color-muted);
227
+ }
228
+
229
+ /* Open and the close: their square starts half a square into the two, its
230
+ control on the lower sixteen of it. */
231
+ .ds-notice--app .ds-notice__open,
232
+ .ds-notice--app .ds-notice__dismiss {
233
+ grid-row: 1 / 3;
234
+ align-self: start;
235
+ margin: calc(1.5 * var(--ds-unit) - var(--ds-control-height)) 0 0;
236
+ }
237
+
238
+ .ds-notice--app .ds-notice__open {
239
+ grid-column: 3;
240
+ text-decoration: none;
241
+ }
242
+
243
+ .ds-notice--app .ds-notice__dismiss {
244
+ grid-column: 4;
245
+ justify-self: end;
246
+ }
@@ -1,4 +1,4 @@
1
- /* version 0.3.0-alpha.18 */
1
+ /* version 0.3.0-alpha.19 */
2
2
 
3
3
  @import "./grid.css";
4
4
  @import "./component-tokens.css";
@@ -1,4 +1,4 @@
1
- /* DS1 — version 0.3.0-alpha.18 */
1
+ /* DS1 — version 0.3.0-alpha.19 */
2
2
  /* A classless stylesheet for native HTML. No JavaScript is required. */
3
3
 
4
4
  /* It draws on Design system 1's measures: the 20px square, 14px type on
@@ -137,7 +137,9 @@ button.ds-row {
137
137
  * row's content (Joachim, 19 October 2026). On a one-square row it is a square high and sits two
138
138
  * pixels around what the row holds: from two above the lower sixteen to two below the row's foot,
139
139
  * and two past the content at each side, its corners the icon tiles' 4px and those two more, so a
140
- * 16px icon stands in it with two all round. A taller row (a stacked tile, `auto`) is filled whole.
140
+ * 16px icon stands in it with two all round. It is drawn over the row, never moving its words or
141
+ * icons off the grid. An open row (`data-open`) wears the same layer. A taller row (a stacked
142
+ * tile, `auto`) is filled whole.
141
143
  * Tokens: `--ds-row-fill-radius` (6px), `--ds-row-fill-overhang` (2px).
142
144
  */
143
145
  a.ds-row,
@@ -349,6 +351,13 @@ button.ds-row[aria-disabled="true"]:hover::after {
349
351
  border-radius: var(--ds-row-radius, var(--ds-row-fill-radius, calc(6px * var(--ds-sf))));
350
352
  background: var(--ds-row-fill-current, var(--ds-fill-hover));
351
353
  }
354
+ /* A plain row (no press target inside) has no padding of its own: its words and icons stand on
355
+ the row's edges, so the layer reaches two past them, as a flush row's does. */
356
+ .ds-row[data-open]:not([data-open="false"]):not(a, button, .ds-row--stacked, .ds-row--auto, :has(.ds-row__target))::after {
357
+ inset: calc(var(--ds-row-height) - var(--ds-control-height) - var(--ds-row-overhang))
358
+ calc(0px - var(--ds-row-overhang))
359
+ calc(0px - var(--ds-row-overhang));
360
+ }
352
361
  .ds-row[data-open]:is(.ds-row--stacked, .ds-row--auto)::after { inset: 0; }
353
362
  @media (forced-colors: active) {
354
363
  .ds-row[data-open]:not([data-open="false"])::after { outline: 1px solid Highlight; outline-offset: -1px; }
@@ -16,6 +16,14 @@
16
16
  * <th colspan="2"> a column group, in a header row
17
17
  * <th scope="rowgroup"> a row group, a heading row in grey
18
18
  * <td aria-selected="true"> a selected cell
19
+ *
20
+ * `data-sheet` makes it a spreadsheet: every cell four squares by one (80 by
21
+ * 20), its 1px lines at its end and foot so the words have 79 by 19, set 4px
22
+ * in on the sixteen, as an Ezo item's are. Column letters and the row numbers
23
+ * (`.ds-table__letters`, `.ds-table__number`, two squares wide) stay in place
24
+ * while the sheet scrolls both ways inside `.ds-table-scroll`; give the table
25
+ * `--ds-table-columns` (the number of data columns) and the scroll a height
26
+ * with `--ds-table-height`.
19
27
  */
20
28
 
21
29
  .ds-table {
@@ -121,3 +129,72 @@
121
129
  color: HighlightText;
122
130
  }
123
131
  }
132
+
133
+ /* A spreadsheet: cells four squares by one, on lines, lettered and numbered. */
134
+ .ds-table[data-sheet] {
135
+ --ds-table-cell: calc(4 * var(--ds-table-row));
136
+ --ds-table-index: calc(2 * var(--ds-table-row));
137
+ --ds-table-text: calc(4px * var(--ds-sf, 1));
138
+ table-layout: fixed;
139
+ width: calc(var(--ds-table-index) + var(--ds-table-columns, 8) * var(--ds-table-cell));
140
+ min-width: 0;
141
+ border-collapse: separate;
142
+ border-spacing: 0;
143
+ }
144
+ .ds-table-scroll > .ds-table[data-sheet] {
145
+ width: calc(var(--ds-table-index) + var(--ds-table-columns, 8) * var(--ds-table-cell));
146
+ min-width: 0;
147
+ }
148
+ .ds-table[data-sheet] :is(th, td) {
149
+ width: var(--ds-table-cell);
150
+ max-width: var(--ds-table-cell);
151
+ height: var(--ds-table-row);
152
+ padding: 0 var(--ds-table-text);
153
+ border-bottom: 1px solid var(--ds-table-rule);
154
+ border-inline-end: 1px solid var(--ds-table-rule);
155
+ overflow: hidden;
156
+ text-overflow: ellipsis;
157
+ }
158
+ .ds-table[data-sheet] :is(th, td):last-child {
159
+ padding-inline-end: var(--ds-table-text);
160
+ }
161
+ .ds-table[data-sheet] .ds-table__number {
162
+ width: var(--ds-table-index);
163
+ max-width: var(--ds-table-index);
164
+ position: sticky;
165
+ inset-inline-start: 0;
166
+ z-index: 1;
167
+ background: var(--ds-table-paper);
168
+ color: var(--ds-title-color, light-dark(#797979, #9a9a9a));
169
+ text-align: center;
170
+ font-variant-numeric: tabular-nums;
171
+ }
172
+ .ds-table[data-sheet] thead :is(th, td) {
173
+ position: sticky;
174
+ top: 0;
175
+ z-index: 2;
176
+ background: var(--ds-table-paper);
177
+ }
178
+ .ds-table[data-sheet] thead tr + tr :is(th, td) {
179
+ top: var(--ds-table-row);
180
+ }
181
+ .ds-table[data-sheet] thead .ds-table__number {
182
+ z-index: 3;
183
+ }
184
+ .ds-table[data-sheet] .ds-table__letters td {
185
+ color: var(--ds-title-color, light-dark(#797979, #9a9a9a));
186
+ text-align: center;
187
+ }
188
+ .ds-table[data-sheet] tbody td:not([data-numeric]) {
189
+ color: inherit;
190
+ }
191
+ .ds-table[data-sheet] [aria-selected="true"] {
192
+ background: transparent;
193
+ color: inherit;
194
+ box-shadow: inset 0 0 0 2px var(--ds-focus-ring, Highlight);
195
+ }
196
+ .ds-table-scroll:has(> .ds-table[data-sheet]) {
197
+ max-height: var(--ds-table-height, none);
198
+ overflow: auto;
199
+ overscroll-behavior: contain;
200
+ }
@@ -1,8 +1,9 @@
1
1
  // ds-message.ts
2
2
  // An inline notice in the flow of the page: a tone, a title and/or text,
3
3
  // actions and an optional dismiss; with `banner`, a strip across the top of
4
- // its positioning context instead. Drawn by 1-root/notice.css, the same
5
- // classes a page without scripts writes.
4
+ // its positioning context instead; with `form="app"`, a row at the top of the
5
+ // page leading to the site's iPhone app (0-face/app-banner.ts decides where).
6
+ // Drawn by 1-root/notice.css, the same classes a page without scripts writes.
6
7
 
7
8
  import { LitElement, html, nothing, unsafeCSS, type PropertyValues } from "lit";
8
9
  import { getText } from "../0-face/i18n";
@@ -10,9 +11,10 @@ import { define } from "../0-face/define";
10
11
  import "./ds-icon";
11
12
  import "./ds-toast";
12
13
  import type { ToastPosition } from "../0-face/toast-queue";
14
+ import { appBannerPlacement, appStoreUrl, closeAppBanner, ensureSmartAppBanner, openApp, type AppBannerOptions, type AppBannerPlacement } from "../0-face/app-banner";
13
15
  export { toast as message } from "../0-face/toast-queue";
14
16
  export type { ToastOptions as MessageOptions, ToastId as MessageId } from "../0-face/toast-queue";
15
- export type MessageForm = "inline" | "banner" | "toast" | "assistant";
17
+ export type MessageForm = "inline" | "banner" | "toast" | "assistant" | "app";
16
18
  import metrics from "../1-root/component-metrics.css?inline";
17
19
  import controls from "../1-root/controls.css?inline";
18
20
  import styles from "./styles/ds-notice.css?inline";
@@ -42,6 +44,19 @@ export interface MessageDismissDetail { tone: MessageTone }
42
44
  * @prop {boolean} banner - A strip one square high across the top of its
43
45
  * positioning context, filled in the tone's colour, its words, actions and
44
46
  * close button on one line; it reserves no room in the flow.
47
+ * @prop {string} form - `inline` (the default), `banner`, `toast`,
48
+ * `assistant`, or `app`: a row at the top of the page leading to the site's
49
+ * iPhone app, with its picture, `heading` (the app's name), `text` (one
50
+ * line about it), Open and a close. On iOS Safari it draws nothing and puts
51
+ * Apple's Smart App Banner on instead; in iOS's other browsers it draws
52
+ * itself; never on a computer, Android, from the home screen or in a web
53
+ * view; once closed, never again in this browser.
54
+ * @prop {string} src - (app) The app's icon.
55
+ * @prop {string} app-id - (app) The App Store id, for Apple's banner and the store address.
56
+ * @prop {string} app-url - (app) The store address, when it is not the App Store's own.
57
+ * @prop {string} open - (app) The address that opens the app at this page.
58
+ * @prop {string} remember - (app) The name the close is remembered by; one per app by default.
59
+ * @prop {boolean} preview - (app) Draw it on any device, for documentation.
45
60
  *
46
61
  * @slot - The text, when it needs more than words: a link, a strong word.
47
62
  * @slot heading - The title, as inline content.
@@ -79,6 +94,12 @@ export class Message extends LitElement {
79
94
  icon: { type: String },
80
95
  dismissible: { type: Boolean, reflect: true },
81
96
  banner: { type: Boolean, reflect: true },
97
+ src: { type: String },
98
+ appId: { type: String, attribute: "app-id" },
99
+ appUrl: { type: String, attribute: "app-url" },
100
+ open: { type: String },
101
+ remember: { type: String },
102
+ preview: { type: Boolean },
82
103
  };
83
104
 
84
105
  static styles = [unsafeCSS(metrics), unsafeCSS(controls), unsafeCSS(styles)];
@@ -102,6 +123,14 @@ export class Message extends LitElement {
102
123
  declare icon: string | undefined;
103
124
  declare dismissible: boolean;
104
125
  declare banner: boolean;
126
+ declare src: string | undefined;
127
+ declare appId: string | undefined;
128
+ declare appUrl: string | undefined;
129
+ declare open: string | undefined;
130
+ declare remember: string | undefined;
131
+ declare preview: boolean;
132
+ /** (app) Where this device shows it, decided before each render. */
133
+ private placement: AppBannerPlacement = "none";
105
134
 
106
135
  /** The role this element last set, so a role the page wrote is left alone. */
107
136
  private ownRole: string | null = null;
@@ -123,6 +152,7 @@ export class Message extends LitElement {
123
152
  this.literal = false;
124
153
  this.dismissible = false;
125
154
  this.banner = false;
155
+ this.preview = false;
126
156
  }
127
157
 
128
158
  /** The tone in effect: an unknown value reads as neutral. */
@@ -146,13 +176,28 @@ export class Message extends LitElement {
146
176
  protected willUpdate(changed: PropertyValues<this>) {
147
177
  super.willUpdate(changed);
148
178
  if (changed.has("tone") || changed.has("form")) this.syncRole();
179
+ if (this.form === "app") this.place();
180
+ else if (changed.get("form") === "app") this.hidden = false;
181
+ }
182
+
183
+ private get app(): AppBannerOptions {
184
+ return { appId: this.appId, appUrl: this.appUrl, open: this.open, key: this.remember };
185
+ }
186
+
187
+ /** Decides where the app banner shows, before it draws, so it never pops in after the page. */
188
+ private place() {
189
+ this.placement = this.preview ? "drawn" : appBannerPlacement(this.app);
190
+ if (this.placement === "safari") ensureSmartAppBanner(this.app);
191
+ this.hidden = this.placement !== "drawn";
192
+ const name = this.words(this.heading) || this.querySelector('[slot="heading"]')?.textContent?.trim();
193
+ if (name && this.getAttribute("aria-label") !== name) this.setAttribute("aria-label", name);
149
194
  }
150
195
 
151
196
  private syncRole() {
152
197
  if (!this.isConnected) return;
153
198
  const current = this.getAttribute("role");
154
199
  if (current !== null && current !== this.ownRole) return;
155
- this.ownRole = this.form === "toast" ? "presentation" : this.resolvedTone === "danger" ? "alert" : "status";
200
+ this.ownRole = this.form === "toast" ? "presentation" : this.form === "app" ? "region" : this.resolvedTone === "danger" ? "alert" : "status";
156
201
  if (current !== this.ownRole) this.setAttribute("role", this.ownRole);
157
202
  }
158
203
 
@@ -178,13 +223,39 @@ export class Message extends LitElement {
178
223
  const event = new CustomEvent<MessageDismissDetail>(this.dismissEvent, {
179
224
  bubbles: true, composed: true, cancelable: true, detail: { tone: this.resolvedTone },
180
225
  });
226
+ // An app banner closed stays closed in this browser, whoever removes it.
227
+ if (this.form === "app" && !this.preview) closeAppBanner(this.app);
181
228
  if (!this.dispatchEvent(event)) return false;
182
229
  this.hidden = true;
183
230
  return true;
184
231
  }
185
232
 
233
+ private openApp(event: MouseEvent) {
234
+ if (!this.open || this.preview) return;
235
+ event.preventDefault();
236
+ openApp(this.app);
237
+ }
238
+
239
+ private renderApp() {
240
+ const heading = this.words(this.heading);
241
+ const text = this.words(this.text);
242
+ const changed = () => this.requestUpdate();
243
+ return html`<div class="ds-notice ds-notice--app" part="message notice">
244
+ <span class="ds-notice__picture" part="picture" aria-hidden="true">
245
+ <slot name="icon" @slotchange=${changed}>${this.src ? html`<img src=${this.src} alt="">` : nothing}</slot>
246
+ </span>
247
+ <div class="ds-notice__title" part="title"><slot name="heading" @slotchange=${changed}>${heading}</slot></div>
248
+ <div class="ds-notice__text" part="text"><slot @slotchange=${changed}></slot>${this.slotted("") ? nothing : text}</div>
249
+ <a class="ds-button ds-button--primary ds-notice__open" part="open" href=${appStoreUrl(this.app) || this.open || nothing} @click=${(event: MouseEvent) => this.openApp(event)}>${getText("Open") || "Open"}</a>
250
+ <button class="ds-button ds-button--icon ds-notice__dismiss" part="dismiss" type="button" aria-label=${getText("Close") || "Close"} @click=${() => this.dismiss()}>
251
+ <ds-icon type="close" aria-hidden="true"></ds-icon>
252
+ </button>
253
+ </div>`;
254
+ }
255
+
186
256
  render() {
187
257
  if (this.form === "toast") return html`<ds-toast .expand=${this.expand} .visibleToasts=${this.visibleToasts} .richColors=${this.richColors} .contained=${this.contained} .label=${this.label} .closeLabel=${this.closeLabel} .position=${this.position} .duration=${this.duration} .closeButton=${this.closeButton}></ds-toast>`;
258
+ if (this.form === "app") return this.placement === "drawn" ? this.renderApp() : nothing;
188
259
  const tone = this.resolvedTone;
189
260
  const heading = this.words(this.heading);
190
261
  const text = this.words(this.text);
@@ -25,8 +25,21 @@
25
25
  position: static;
26
26
  }
27
27
 
28
+ /* The app banner stands in the flow, at the top of the page. */
29
+ :host([form="app"]) {
30
+ display: block;
31
+ }
32
+
28
33
  /* Slotted words take the notice's lines rather than their own margins. */
29
34
  ::slotted([slot="heading"]),
30
35
  ::slotted(:not([slot])) {
31
36
  margin: 0;
32
37
  }
38
+
39
+ /* The app's own picture in the icon slot fills its two squares. */
40
+ ::slotted(img[slot="icon"]) {
41
+ display: block;
42
+ width: 100%;
43
+ height: 100%;
44
+ object-fit: cover;
45
+ }
@@ -15,8 +15,8 @@ const REFUSED = 1400;
15
15
  * from the keyboard. `context-selector` delegates one menu to many rows of a
16
16
  * long list, and focus returns to the row that opened it.
17
17
  *
18
- * It wears Ezo's gap face: each row carries the ink as its fill with the
19
- * paper's colour for its words, rows a pixel apart and groups two (an
18
+ * It wears Ezo's gap face: each row is black with white words in both themes
19
+ * (the slate in dark, darker than the page), rows a pixel apart and groups two (an
20
20
  * `hr data-ds-menu-separator` between them, never a line), the page showing
21
21
  * through the gaps, the outer corners rounded as one silhouette, and an icon
22
22
  * at a row's end. A group left empty leaves no gap. A row hovered or focused