@apliteni/apliteni-ui 0.33.1 → 0.34.1
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/package.json +1 -1
- package/react/README.md +87 -1
- package/react/dist/index.d.ts +122 -2
- package/react/dist/index.js +442 -11
- package/src/components/dropdown.js +36 -15
- package/src/components/shell.js +124 -37
- package/src/components/tooltip.js +3 -0
- package/src/styles/back.css +12 -1
- package/src/styles/dropdown.css +37 -10
- package/src/styles/layout.css +124 -6
- package/src/styles/topbar.css +1 -1
package/src/components/shell.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// The kit's one page shell: a full-height rail beside one <main
|
|
2
|
-
// is a thin preset over it that keeps the topbar. wireShell() once
|
|
3
|
-
// wires the fold, the nav's groups and the reader's menu.
|
|
1
|
+
// The kit's one page shell: a full-height rail beside one <main>, in two layouts.
|
|
2
|
+
// accountShell() is a thin preset over it that keeps the topbar. wireShell() once
|
|
3
|
+
// after mounting wires the fold, the nav's groups and the reader's menu.
|
|
4
4
|
// why: docs/specification.md#the-page-shell
|
|
5
5
|
import { topbar as productTopbar } from './topbar.js';
|
|
6
6
|
import { esc, icon } from './index.js';
|
|
@@ -9,6 +9,7 @@ import { dropdown, wireDropdown } from './dropdown.js';
|
|
|
9
9
|
import { backLink } from './back.js';
|
|
10
10
|
import { prism } from '../assets/brand.js';
|
|
11
11
|
import { ACCOUNT_NAV, toMenuTuple, initials } from './account-nav.js';
|
|
12
|
+
import { paletteHotkey } from './command-palette.js';
|
|
12
13
|
|
|
13
14
|
// The one account navigation definition lives in account-nav.js because topbar.js needs
|
|
14
15
|
// it too; re-exported here so the name docs/library.md publishes keeps working.
|
|
@@ -78,6 +79,26 @@ const toTopbar = (t) => {
|
|
|
78
79
|
return out;
|
|
79
80
|
};
|
|
80
81
|
|
|
82
|
+
// ---- the two layouts, and the two widths ---------------------------------
|
|
83
|
+
//
|
|
84
|
+
// Each is a name the kit knows or the default — read strictly, and not through
|
|
85
|
+
// String(), which turned `['topbar']` into a layout and drew half a page.
|
|
86
|
+
// why: docs/specification.md#the-page-shell
|
|
87
|
+
const toLayout = (v) => (v === 'topbar' ? 'topbar' : 'rail');
|
|
88
|
+
|
|
89
|
+
// why: docs/specification.md#widths
|
|
90
|
+
const toWidth = (v) => (v === 'wide' ? 'wide' : 'centered');
|
|
91
|
+
|
|
92
|
+
// `search: 'palette-id'`, or the same id in `{ palette }` with a placeholder beside
|
|
93
|
+
// it. No palette to open, no field — the argument `signOutHref` takes.
|
|
94
|
+
// why: docs/specification.md#the-page-shell
|
|
95
|
+
const toSearch = (v) => {
|
|
96
|
+
const given = typeof v === 'string' ? { palette: v } : (isRecord(v) && !Array.isArray(v) ? v : null);
|
|
97
|
+
const palette = given && typeof given.palette === 'string' ? given.palette.trim() : '';
|
|
98
|
+
if (!palette) return null;
|
|
99
|
+
return { palette, placeholder: str(given.placeholder) || 'Search or run a command…' };
|
|
100
|
+
};
|
|
101
|
+
|
|
81
102
|
// `maxWidth` lands inside a style attribute, so a length is all this accepts — a number
|
|
82
103
|
// and a unit, or `none`. Anything else yields '' and the caller writes no style
|
|
83
104
|
// attribute, falling through to --measure. It must REMOVE the property rather than pass
|
|
@@ -137,6 +158,7 @@ const railToggle = (collapsed) =>
|
|
|
137
158
|
// this file re-checks a value that has been through here.
|
|
138
159
|
const SHAPES = {
|
|
139
160
|
nav: toItems, crumbs: toCrumbs, back: toBack, account: toReader, maxWidth: mainMax, topbar: toTopbar,
|
|
161
|
+
layout: toLayout, width: toWidth, search: toSearch,
|
|
140
162
|
// Drawn by default; `collapsible: false` is the way out, for a page that will
|
|
141
163
|
// never call wireShell(). why: docs/specification.md#the-page-shell
|
|
142
164
|
collapsible: (v) => v !== false,
|
|
@@ -160,44 +182,71 @@ function settle(options) {
|
|
|
160
182
|
// the fold takes away. `named` is the accessible name when nothing else carries
|
|
161
183
|
// one; under the menu trigger it is null, because the button is named by the words
|
|
162
184
|
// inside it. why: docs/specification.md#the-page-shell
|
|
163
|
-
const readerFace = (name, email, named) =>
|
|
185
|
+
const readerFace = (name, email, named, markOnly = false) =>
|
|
164
186
|
`<span class="ui-app__av"${named ? ` role="img" aria-label="Signed in as ${esc(named)}"` : ' aria-hidden="true"'}>`
|
|
165
187
|
+ `${esc(initials(name, email))}</span>`
|
|
166
|
-
+ `<span class="ui-app__who"${named ? ' aria-hidden="true"' : ''}>`
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
188
|
+
+ (markOnly ? '' : `<span class="ui-app__who"${named ? ' aria-hidden="true"' : ''}>`
|
|
189
|
+
+ (name ? `<b>${esc(name)}</b>` : '')
|
|
190
|
+
+ (email ? `<span>${esc(email)}</span>` : '')
|
|
191
|
+
+ `</span>`);
|
|
170
192
|
|
|
171
193
|
// Who is signed in, and the one action on the session. A sibling of the <nav>, not
|
|
172
194
|
// its footer: a name and address are not navigation. Given a sign-out href the block
|
|
173
195
|
// is the trigger of the kit's own dropdown(), with Sign out inside it; without one
|
|
174
196
|
// there is no menu, and with nobody signed in there is no block.
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
|
|
197
|
+
//
|
|
198
|
+
// One block, two places: only the direction it opens is the layout's answer.
|
|
199
|
+
// `portal: true` either way — the rail and the band are both sticky.
|
|
200
|
+
function readerBlock({ name, email }, signOutHref, { band = false } = {}) {
|
|
178
201
|
if (!name && !email) return '';
|
|
202
|
+
const cls = `ui-app__user${band ? ' ui-app__user--bar' : ''}`;
|
|
179
203
|
const who = [name, email].filter(Boolean).join(', ');
|
|
204
|
+
// The name is said once, by whichever part is on screen: the two lines in the rail,
|
|
205
|
+
// an aria-label on the band where the mark is the whole trigger.
|
|
206
|
+
const face = band ? readerFace(name, email, null, true) : readerFace(name, email, null);
|
|
180
207
|
if (!signOutHref) {
|
|
181
|
-
return `<div class="
|
|
208
|
+
return `<div class="${cls}">${readerFace(name, email, who, band)}</div>`;
|
|
182
209
|
}
|
|
183
|
-
// The head says who the menu belongs to — and on a folded rail
|
|
184
|
-
// a sighted reader can read the address.
|
|
210
|
+
// The head says who the menu belongs to — and on a folded rail, or on a band, it is
|
|
211
|
+
// the only place a sighted reader can read the address.
|
|
185
212
|
const head = `<div class="ui-dropdown__head">`
|
|
186
213
|
+ (name ? `<b>${esc(name)}</b>` : '')
|
|
187
214
|
+ (email ? `<span>${esc(email)}</span>` : '')
|
|
188
215
|
+ `</div>`;
|
|
189
|
-
return `<div class="
|
|
216
|
+
return `<div class="${cls}">${dropdown({
|
|
190
217
|
variant: 'menu',
|
|
191
218
|
portal: true,
|
|
192
|
-
direction: 'up',
|
|
219
|
+
direction: band ? 'down' : 'up',
|
|
220
|
+
align: band ? 'end' : 'start',
|
|
221
|
+
chevron: !band,
|
|
222
|
+
ariaLabel: band ? `Signed in as ${who}` : undefined,
|
|
193
223
|
triggerClass: 'ui-app__user-trigger',
|
|
194
|
-
triggerContent:
|
|
224
|
+
triggerContent: face,
|
|
195
225
|
panelClass: 'ui-app__user-panel',
|
|
196
226
|
header: head,
|
|
197
227
|
items: [{ label: 'Sign out', icon: 'logout', href: signOutHref, danger: true }],
|
|
198
228
|
})}</div>`;
|
|
199
229
|
}
|
|
200
230
|
|
|
231
|
+
// A field to look at, a button to press. `data-cmdk-open` and the cap's class are the
|
|
232
|
+
// palette's own, and the cap is NOT aria-hidden: it is half the button's name.
|
|
233
|
+
// why: docs/specification.md#the-page-shell
|
|
234
|
+
const searchField = ({ palette, placeholder }) =>
|
|
235
|
+
`<button type="button" class="ui-app__search" data-cmdk-open="${esc(palette)}" aria-haspopup="dialog">`
|
|
236
|
+
+ `<span class="ui-app__search-ic" aria-hidden="true">${icon('search')}</span>`
|
|
237
|
+
+ `<span class="ui-app__search-txt">${esc(placeholder)}</span>`
|
|
238
|
+
+ `<kbd class="ui-cmdk__key" data-palette-hotkey>${esc(paletteHotkey())}</kbd>`
|
|
239
|
+
+ `</button>`;
|
|
240
|
+
|
|
241
|
+
// The band: search at its start, the reader at its end, beside the rail rather than
|
|
242
|
+
// across the top of both. why: docs/specification.md#the-page-shell
|
|
243
|
+
const shellBar = (search, account, signOutHref) =>
|
|
244
|
+
`<header class="ui-app__bar">`
|
|
245
|
+
+ (search ? searchField(search) : '')
|
|
246
|
+
+ `<span class="ui-app__bar-gap"></span>`
|
|
247
|
+
+ readerBlock(account, signOutHref, { band: true })
|
|
248
|
+
+ `</header>`;
|
|
249
|
+
|
|
201
250
|
// Unique-per-render suffix for the brand mark's clip id — the same reason
|
|
202
251
|
// nav.js keeps a module counter. Two shells on one page must not collide.
|
|
203
252
|
let _shellUid = 0;
|
|
@@ -222,7 +271,12 @@ export function appShell(options = {}) {
|
|
|
222
271
|
maxWidth,
|
|
223
272
|
collapsible,
|
|
224
273
|
collapsed,
|
|
274
|
+
layout,
|
|
275
|
+
width,
|
|
276
|
+
search,
|
|
225
277
|
} = settle(options);
|
|
278
|
+
// The layout's one branch, read once. Everything below asks this and not the string.
|
|
279
|
+
const banded = layout === 'topbar';
|
|
226
280
|
const up = back ? backLink(back) : '';
|
|
227
281
|
// No footer slot: the nav is places to go, and the one thing that was in it —
|
|
228
282
|
// sign out — is in the reader's menu at the rail's foot.
|
|
@@ -232,10 +286,15 @@ export function appShell(options = {}) {
|
|
|
232
286
|
activeIs: up ? 'section' : 'page',
|
|
233
287
|
ariaLabel: navLabel,
|
|
234
288
|
});
|
|
289
|
+
// One band over a page, never two: the banded layout draws its own, so the
|
|
290
|
+
// compatibility bag is not drawn beside it. why: docs/specification.md#the-page-shell
|
|
291
|
+
const compat = banded ? null : topbar;
|
|
235
292
|
// The topbar already says the product word, so the rail head steps aside when there is
|
|
236
293
|
// one. The word is the link's only text and the narrow rail folds it out of view, so
|
|
237
|
-
// the name is written out — the mark itself is aria-hidden.
|
|
238
|
-
|
|
294
|
+
// the name is written out — the mark itself is aria-hidden. The banded layout's own
|
|
295
|
+
// bar says no word, so there the rail keeps the lockup: that layout's head band IS
|
|
296
|
+
// the product's mark. why: docs/specification.md#the-page-shell
|
|
297
|
+
const brand = compat ? '' : `<a class="ui-app__brand" href="${esc(brandHref)}" aria-label="${esc(word)}">`
|
|
239
298
|
+ `${prism(`appb-${++_shellUid}`, 24)}<span>${esc(word)}</span></a>`;
|
|
240
299
|
// A <div>, not an <aside>: <aside> is the `complementary` landmark, and this holds the
|
|
241
300
|
// page's primary navigation and the signed-in reader. The <nav> inside it is already
|
|
@@ -244,27 +303,40 @@ export function appShell(options = {}) {
|
|
|
244
303
|
// caller left the choice to the reader; wireShell() applies the stored one there.
|
|
245
304
|
const folded = collapsible && collapsed === true;
|
|
246
305
|
const auto = collapsible && collapsed === null ? ' data-rail="auto"' : '';
|
|
306
|
+
// Where this stands is the one thing the two layouts disagree about on the rail.
|
|
307
|
+
const fold = collapsible ? railToggle(folded) : '';
|
|
247
308
|
// The head band: the product's mark, and the rail's own control at the far end of
|
|
248
309
|
// the same line, under one rule. Either may be absent — a shell with a topbar says
|
|
249
310
|
// the word up there, and `collapsible: false` draws no toggle — so the band itself
|
|
250
311
|
// goes when both are.
|
|
251
|
-
const
|
|
252
|
-
|
|
312
|
+
const inHead = banded ? '' : fold;
|
|
313
|
+
const head = brand || inHead
|
|
314
|
+
? `<div class="ui-app__head">${brand}${inHead}</div>`
|
|
253
315
|
: '';
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
<main class="ui-app__main"${maxWidth ? ` style="--ui-app-main: ${maxWidth}"` : ''}>
|
|
316
|
+
// The rail's foot: the reader, or the control that took their place. One block
|
|
317
|
+
// either way. why: docs/specification.md#the-page-shell
|
|
318
|
+
const foot = banded
|
|
319
|
+
? (fold ? `<div class="ui-app__foot">${fold}</div>` : '')
|
|
320
|
+
: readerBlock(account, signOutHref);
|
|
321
|
+
const main = `<main class="ui-app__main${width === 'wide' ? ' ui-app__main--wide' : ''}"${maxWidth ? ` style="--ui-app-main: ${maxWidth}"` : ''}>
|
|
261
322
|
${up || (crumbs.length ? breadcrumbs({ items: crumbs }) : '')}
|
|
262
323
|
${title ? `<h1>${title}</h1>` : ''}
|
|
263
324
|
${sub ? `<p class="ui-app__sub">${sub}</p>` : ''}
|
|
264
325
|
<div class="ui-app__body">${body}</div>
|
|
265
|
-
</main
|
|
326
|
+
</main>`;
|
|
327
|
+
// The second column is a box of its own: the band and the page are two rows of it.
|
|
328
|
+
const well = banded
|
|
329
|
+
? `<div class="ui-app__well">${shellBar(search, account, signOutHref)}${main}</div>`
|
|
330
|
+
: main;
|
|
331
|
+
const grid = `<div class="ui-app${banded ? ' ui-app--topbar' : ''}${folded ? ' is-collapsed' : ''}"${auto}>
|
|
332
|
+
<div class="ui-app__rail">
|
|
333
|
+
${head}
|
|
334
|
+
${rail}
|
|
335
|
+
${foot}
|
|
336
|
+
</div>
|
|
337
|
+
${well}
|
|
266
338
|
</div>`;
|
|
267
|
-
return
|
|
339
|
+
return compat ? `<div class="ui-app-page">${productTopbar(compat)}${grid}</div>` : grid;
|
|
268
340
|
}
|
|
269
341
|
|
|
270
342
|
// ---- Behaviour -----------------------------------------------------------
|
|
@@ -275,13 +347,14 @@ export function appShell(options = {}) {
|
|
|
275
347
|
const _wiredDocs = new WeakSet();
|
|
276
348
|
const _unpersisted = new WeakSet();
|
|
277
349
|
|
|
278
|
-
// The toggle, addressed from the rail that owns it:
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
350
|
+
// The toggle, addressed from the rail that owns it: a fold row of a shell's own rail
|
|
351
|
+
// and nowhere else, so a stray [data-rail-toggle] in the page body folds nothing. The
|
|
352
|
+
// path is written once — the listener, the reflector and wireShell() all have to mean
|
|
353
|
+
// the same control. A descendant and not a child, because the two layouts stand the
|
|
354
|
+
// row in two places: the head band, or the rail's foot.
|
|
282
355
|
// why: docs/specification.md#the-page-shell
|
|
283
|
-
const FOLD_PATH = '.ui-
|
|
284
|
-
const RAIL_FOLD = `.ui-app__rail
|
|
356
|
+
const FOLD_PATH = '.ui-app__fold-row > [data-rail-toggle]';
|
|
357
|
+
const RAIL_FOLD = `.ui-app__rail ${FOLD_PATH}`;
|
|
285
358
|
|
|
286
359
|
// Under an opted-out root? Steps out of a shadow root through its host.
|
|
287
360
|
const optedOut = (node) => {
|
|
@@ -293,7 +366,7 @@ function setRail(app, collapsed) {
|
|
|
293
366
|
app.classList.toggle('is-collapsed', collapsed);
|
|
294
367
|
const rail = app.querySelector(':scope > .ui-app__rail');
|
|
295
368
|
if (!rail) return;
|
|
296
|
-
for (const btn of rail.querySelectorAll(`:scope
|
|
369
|
+
for (const btn of rail.querySelectorAll(`:scope ${FOLD_PATH}`)) {
|
|
297
370
|
btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
|
298
371
|
btn.setAttribute('aria-label', railName(collapsed));
|
|
299
372
|
const label = btn.querySelector('.ui-nav__label');
|
|
@@ -334,6 +407,12 @@ export function wireShell(root = document, { persist } = {}) {
|
|
|
334
407
|
// The reader's menu is a dropdown() like any other, so it is wired like any
|
|
335
408
|
// other. Idempotent, and a shell with no account draws none to find.
|
|
336
409
|
wireDropdown(root);
|
|
410
|
+
// A server cannot know which key the reader holds, so the browser corrects it here —
|
|
411
|
+
// off the ROOT's window, not the global one. why: docs/specification.md#the-page-shell
|
|
412
|
+
const here = root.nodeType === 9 ? root : root.ownerDocument;
|
|
413
|
+
const view = here && here.defaultView;
|
|
414
|
+
const key = paletteHotkey(view && view.navigator ? view.navigator.platform : '');
|
|
415
|
+
for (const kbd of root.querySelectorAll('[data-palette-hotkey]')) kbd.textContent = key;
|
|
337
416
|
const doc = root.nodeType === 9 ? root : root.ownerDocument;
|
|
338
417
|
listen(doc);
|
|
339
418
|
if (persist === false) _unpersisted.add(root);
|
|
@@ -368,6 +447,9 @@ export function accountShell({
|
|
|
368
447
|
signOutHref = '#logout',
|
|
369
448
|
collapsible = true,
|
|
370
449
|
collapsed,
|
|
450
|
+
layout,
|
|
451
|
+
width,
|
|
452
|
+
search,
|
|
371
453
|
} = {}) {
|
|
372
454
|
// The same normaliser appShell() runs, called once here so the rail and the
|
|
373
455
|
// topbar menu are handed one list rather than two readings of `nav`.
|
|
@@ -389,6 +471,11 @@ export function accountShell({
|
|
|
389
471
|
signOutHref,
|
|
390
472
|
collapsible,
|
|
391
473
|
collapsed,
|
|
474
|
+
// Settled nowhere but appShell(). Under `layout: 'topbar'` the bag below is not
|
|
475
|
+
// drawn, which is what that layout costs the preset.
|
|
476
|
+
layout,
|
|
477
|
+
width,
|
|
478
|
+
search,
|
|
392
479
|
topbar: {
|
|
393
480
|
word,
|
|
394
481
|
view: 'text',
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
import { esc } from './index.js';
|
|
15
15
|
|
|
16
16
|
const cx = (...a) => a.filter(Boolean).join(' ');
|
|
17
|
+
/* classes: ui-tip__label ui-tip__value ui-tip__detail — built from `k` below, so
|
|
18
|
+
a sweep reading class names off the source cannot see them.
|
|
19
|
+
why: CONTRIBUTING.md#a-spelling-the-sweep-cannot-see-costs-coverage-in-silence */
|
|
17
20
|
const PARTS = ['label', 'value', 'detail'];
|
|
18
21
|
const px = (n) => (typeof n === 'number' && Number.isFinite(n) ? `${n}px` : null);
|
|
19
22
|
let seq = 0;
|
package/src/styles/back.css
CHANGED
|
@@ -7,10 +7,12 @@
|
|
|
7
7
|
* ========================================================================== */
|
|
8
8
|
|
|
9
9
|
/* Its own line, and only as wide as its words, so the hover ground does not run
|
|
10
|
-
the width of the column
|
|
10
|
+
the width of the column — and never wider than it, which `fit-content` alone
|
|
11
|
+
does not give once the label below is `nowrap`. why: #303 */
|
|
11
12
|
.ui-back {
|
|
12
13
|
display: flex;
|
|
13
14
|
width: fit-content;
|
|
15
|
+
max-width: 100%;
|
|
14
16
|
align-items: center;
|
|
15
17
|
gap: var(--space-1);
|
|
16
18
|
/* WCAG 2.5.8's 24px: the line of text alone is 17px tall. */
|
|
@@ -40,3 +42,12 @@
|
|
|
40
42
|
/* In the page shell it takes the trail's place, and the trail's distance from
|
|
41
43
|
the title with it. */
|
|
42
44
|
.ui-app__main > .ui-back { margin-bottom: var(--space-5); }
|
|
45
|
+
|
|
46
|
+
/* The one part that can run long. It clips rather than wrapping, as .ui-nav__label
|
|
47
|
+
does. why: docs/specification.md#the-back-link */
|
|
48
|
+
.ui-back__label {
|
|
49
|
+
min-width: 0;
|
|
50
|
+
overflow: hidden;
|
|
51
|
+
text-overflow: ellipsis;
|
|
52
|
+
white-space: nowrap;
|
|
53
|
+
}
|
package/src/styles/dropdown.css
CHANGED
|
@@ -28,16 +28,23 @@
|
|
|
28
28
|
.ui-dropdown__pre { color: var(--muted); }
|
|
29
29
|
.ui-dropdown__value { color: var(--strong); font-weight: 600; letter-spacing: 0.01em; }
|
|
30
30
|
|
|
31
|
-
/* Chevron — border caret that flips when the container is .open
|
|
31
|
+
/* Chevron — border caret that flips when the container is .open. Its ink is an L
|
|
32
|
+
on a rotated square, so centring the BOX leaves the mark off centre; --caret-off
|
|
33
|
+
is that gap, derived rather than typed.
|
|
34
|
+
why: docs/specification.md#the-dropdown-panel */
|
|
32
35
|
.ui-dropdown__chevron {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
--caret: 8px;
|
|
37
|
+
--caret-ink: 1.6px;
|
|
38
|
+
--caret-off: calc((var(--caret) - var(--caret-ink)) / 2.8284); /* 2√2 — the 45° turn's projection */
|
|
39
|
+
width: var(--caret); height: var(--caret);
|
|
40
|
+
border-right: var(--caret-ink) solid var(--muted);
|
|
41
|
+
border-bottom: var(--caret-ink) solid var(--muted);
|
|
42
|
+
/* Shift BEFORE rotate, so it lands in page space rather than on the turned axis. */
|
|
43
|
+
transform: translateY(calc(var(--caret-off) * -1)) rotate(45deg);
|
|
37
44
|
transition: transform var(--dur-fast) var(--ease);
|
|
38
45
|
flex: none;
|
|
39
46
|
}
|
|
40
|
-
.ui-dropdown.open .ui-dropdown__chevron { transform:
|
|
47
|
+
.ui-dropdown.open .ui-dropdown__chevron { transform: translateY(var(--caret-off)) rotate(-135deg); }
|
|
41
48
|
|
|
42
49
|
/* Panel — the popover surface */
|
|
43
50
|
.ui-dropdown__panel {
|
|
@@ -45,6 +52,10 @@
|
|
|
45
52
|
the portal's JS, so an upward panel cannot drift from a downward one.
|
|
46
53
|
why: docs/specification.md#the-dropdown-panel */
|
|
47
54
|
--ui-dropdown-gap: 9px;
|
|
55
|
+
/* Named for the same reason the offset is: a block pinned to an edge has to
|
|
56
|
+
bleed back through it, and #306 is a page copying the number by hand.
|
|
57
|
+
why: docs/specification.md#the-dropdown-panel */
|
|
58
|
+
--ui-dropdown-pad: 6px;
|
|
48
59
|
/* The panel states its own role rather than inheriting one, because `portal:
|
|
49
60
|
true` moves it onto <body> and an inherited face is then decided by where
|
|
50
61
|
the panel was MOUNTED rather than by what it is. Measured: the same dropdown
|
|
@@ -65,7 +76,7 @@
|
|
|
65
76
|
border: 1px solid var(--border-strong);
|
|
66
77
|
box-shadow: inset 0 0 0 1px var(--elev-edge, var(--border)), var(--elev-drop);
|
|
67
78
|
border-radius: var(--radius-lg);
|
|
68
|
-
padding:
|
|
79
|
+
padding: var(--ui-dropdown-pad);
|
|
69
80
|
opacity: 0;
|
|
70
81
|
visibility: hidden;
|
|
71
82
|
/* Drawn for the whole of the fade out, so the rows do not vanish mid-fade — and a
|
|
@@ -203,13 +214,23 @@
|
|
|
203
214
|
/* Separator */
|
|
204
215
|
.ui-dropdown__sep { height: 1px; background: var(--border); margin: 5px 4px; }
|
|
205
216
|
|
|
206
|
-
/* Optional pinned
|
|
207
|
-
|
|
217
|
+
/* Optional pinned head/foot blocks — symmetrical: one inner padding, and each
|
|
218
|
+
bleeding to the edge it sits on, lining the edge it faces.
|
|
219
|
+
why: docs/specification.md#the-dropdown-panel */
|
|
220
|
+
.ui-dropdown__panel > .ui-dropdown__head,
|
|
221
|
+
.ui-dropdown__panel > .ui-dropdown__foot {
|
|
208
222
|
padding: 11px 13px;
|
|
209
|
-
|
|
223
|
+
}
|
|
224
|
+
.ui-dropdown__panel > .ui-dropdown__head {
|
|
225
|
+
margin: calc(var(--ui-dropdown-pad) * -1) calc(var(--ui-dropdown-pad) * -1) 5px;
|
|
210
226
|
border-bottom: 1px solid var(--border);
|
|
211
227
|
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
|
212
228
|
}
|
|
229
|
+
.ui-dropdown__panel > .ui-dropdown__foot {
|
|
230
|
+
margin: 5px calc(var(--ui-dropdown-pad) * -1) calc(var(--ui-dropdown-pad) * -1);
|
|
231
|
+
border-top: 1px solid var(--border);
|
|
232
|
+
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
|
|
233
|
+
}
|
|
213
234
|
|
|
214
235
|
/* Search — `search: true` pins a field above the rows and filters them as the
|
|
215
236
|
reader types. The rows move into a list of their own, so the field stays put
|
|
@@ -246,6 +267,12 @@
|
|
|
246
267
|
min-height: 38px;
|
|
247
268
|
transition: border-color var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease);
|
|
248
269
|
}
|
|
270
|
+
/* Light sinks the field one rung less far: its panel is the only pure white the
|
|
271
|
+
kit paints, so --surface-2 falls two rungs under it rather than one. The
|
|
272
|
+
numbers are in the spec and the gate is stories/dropdown-field-ground.test.js.
|
|
273
|
+
why: docs/specification.md#elevation */
|
|
274
|
+
:root[data-theme="light"] .ui-dropdown__search-input { background: var(--bg); }
|
|
275
|
+
|
|
249
276
|
.ui-dropdown__search-input::placeholder { color: var(--muted); }
|
|
250
277
|
.ui-dropdown__search-input:hover { border-color: var(--border-strong); }
|
|
251
278
|
.ui-dropdown__search-input:focus-visible { outline: none; border-color: var(--accent); box-shadow: var(--ring); }
|
package/src/styles/layout.css
CHANGED
|
@@ -52,9 +52,13 @@
|
|
|
52
52
|
transition: width var(--dur-med) var(--ease);
|
|
53
53
|
}
|
|
54
54
|
/* Each block of the rail keeps the open column while the box closes over it. The
|
|
55
|
-
nav declares its own; these are the
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
nav declares its own; these are the three that are not the nav. Scoped to the rail,
|
|
56
|
+
because the topbar layout draws the reader's block in its band instead and a column
|
|
57
|
+
the rail's width is no measurement for a box on a 52px line.
|
|
58
|
+
why: docs/specification.md#the-page-shell */
|
|
59
|
+
.ui-app__rail > .ui-app__head,
|
|
60
|
+
.ui-app__rail > .ui-app__user,
|
|
61
|
+
.ui-app__rail > .ui-app__foot { width: var(--ui-nav-col); flex: none; }
|
|
58
62
|
/* The rail's hover WAS --surface-2, so moving the rail onto it would have made
|
|
59
63
|
hovering a row do nothing. The danger row and the current row are left out by
|
|
60
64
|
name, because this rule outranks the states nav.css writes for them.
|
|
@@ -105,7 +109,7 @@
|
|
|
105
109
|
smallest of three weak signals doing the most work, so it grows with the row. */
|
|
106
110
|
/* Re-tuned at #295, which took the light rail down a step with the page. Below 720px
|
|
107
111
|
the glyph is the whole row, so it is held at text grade rather than at 1.4.11's bar —
|
|
108
|
-
stories/apps/shell-states.test.js:
|
|
112
|
+
stories/apps/shell-states.test.js:1400 `a resting glyph is dimmer but still legible`. */
|
|
109
113
|
.ui-app__rail .ui-nav__ic svg { opacity: 0.66; }
|
|
110
114
|
.ui-app__rail .ui-nav__item.is-active .ui-nav__ic svg,
|
|
111
115
|
.ui-app__rail .ui-nav__item.is-current .ui-nav__ic svg { opacity: 1; stroke: currentColor; } /* motion: still — the glyph brightens with its row, whose own highlight already transitions */
|
|
@@ -128,6 +132,18 @@
|
|
|
128
132
|
padding-top: var(--space-5);
|
|
129
133
|
border-top: 1px solid var(--border);
|
|
130
134
|
}
|
|
135
|
+
/* The other thing that can close a rail: where the reader's block is in the band,
|
|
136
|
+
the fold's control takes its place under the same rule.
|
|
137
|
+
why: docs/specification.md#the-page-shell */
|
|
138
|
+
.ui-app__foot {
|
|
139
|
+
display: flex;
|
|
140
|
+
align-items: center;
|
|
141
|
+
padding-top: var(--space-5);
|
|
142
|
+
border-top: 1px solid var(--border);
|
|
143
|
+
}
|
|
144
|
+
/* On the glyph column from the first frame, so the fold moves it nowhere — the ride
|
|
145
|
+
along the closing edge belongs to the head band, where the mark holds that column. */
|
|
146
|
+
.ui-app__foot > .ui-app__fold-row { margin-inline-start: 0; }
|
|
131
147
|
/* The avatar is the reader block's own glyph, so half the difference between the
|
|
132
148
|
glyph column and itself is the inset that centres it on the line every glyph above
|
|
133
149
|
it stands on. The arithmetic and not the 5.5 it comes to: both numbers are declared
|
|
@@ -231,8 +247,14 @@
|
|
|
231
247
|
where the control arrives: below 720px the toggle is not drawn, so the 720px block
|
|
232
248
|
gives the lockup back rather than leaving an empty band with a hairline under it.
|
|
233
249
|
why: docs/specification.md#the-page-shell */
|
|
234
|
-
:where(.ui-app.is-collapsed) .ui-app__fold-row { inset-inline-start: calc(var(--ui-nav-strip) - var(--ui-nav-col)); }
|
|
235
|
-
|
|
250
|
+
:where(.ui-app.is-collapsed) .ui-app__head > .ui-app__fold-row { inset-inline-start: calc(var(--ui-nav-strip) - var(--ui-nav-col)); }
|
|
251
|
+
/* …and only in the layout where something arrives on the mark's column. In the
|
|
252
|
+
topbar layout the toggle is at the rail's foot, nothing lands where the mark
|
|
253
|
+
stands, and a lockup faded out there leaves a band with nothing in it and its
|
|
254
|
+
hairline still under it — the defect the 720px block below exists to avoid. So
|
|
255
|
+
that layout keeps the mark and lets the shared rule above fold away its words.
|
|
256
|
+
why: docs/specification.md#the-page-shell */
|
|
257
|
+
:where(.ui-app:not(.ui-app--topbar).is-collapsed) .ui-app__brand { opacity: 0; visibility: hidden; pointer-events: none; }
|
|
236
258
|
:where(.ui-app.is-collapsed) .ui-app__rail .ui-nav__sub { margin-inline-start: 0; padding-inline-start: 0; border-inline-start-width: 0; }
|
|
237
259
|
:where(.ui-app.is-collapsed) .ui-app__rail .ui-nav__item:not(:has(.ui-nav__ic))::after { opacity: 0.62; }
|
|
238
260
|
/* The label IS the chip. `fixed` is the fallback placement, out of the rail's own
|
|
@@ -277,6 +299,97 @@
|
|
|
277
299
|
margin-inline: auto;
|
|
278
300
|
padding: 40px clamp(24px, 4vw, 56px) 72px;
|
|
279
301
|
}
|
|
302
|
+
/* The whole of the difference: `centered` is the rule above and `wide` takes its cap
|
|
303
|
+
off. `maxWidth` is the number under either name. why: docs/specification.md#widths */
|
|
304
|
+
.ui-app__main--wide { max-width: var(--ui-app-main, none); }
|
|
305
|
+
|
|
306
|
+
/* -- The topbar layout ----------------------------------------------------- */
|
|
307
|
+
/* The band stands BESIDE the rail, not across the top of both, which is the
|
|
308
|
+
arrangement where the rail's head band and the band close at the same height — what
|
|
309
|
+
the three heights below are for. Level, not one continuous stroke: the rail insets
|
|
310
|
+
its own rule, and in light that half is 1.009:1 on the rail's ground.
|
|
311
|
+
why: docs/specification.md#the-second-layout */
|
|
312
|
+
.ui-app { --ui-app-band: 52px; }
|
|
313
|
+
.ui-app__well { display: flex; flex-direction: column; min-width: 0; }
|
|
314
|
+
.ui-app__bar {
|
|
315
|
+
display: flex;
|
|
316
|
+
align-items: center;
|
|
317
|
+
gap: var(--space-3);
|
|
318
|
+
height: var(--ui-app-band);
|
|
319
|
+
flex: none;
|
|
320
|
+
padding-inline: clamp(24px, 4vw, 56px);
|
|
321
|
+
background: var(--bg);
|
|
322
|
+
border-bottom: 1px solid var(--border);
|
|
323
|
+
/* Sticky for the reason the rail beside it is: chrome that scrolls away is chrome
|
|
324
|
+
you scroll back up for. */
|
|
325
|
+
position: sticky;
|
|
326
|
+
top: 0;
|
|
327
|
+
z-index: var(--z-sticky);
|
|
328
|
+
}
|
|
329
|
+
.ui-app__bar-gap { flex: 1 1 auto; }
|
|
330
|
+
/* The rail's two bands take the band's height, so the rule under the head lands on
|
|
331
|
+
the line the band's own rule lands on. The rail gives up its top inset to buy it. */
|
|
332
|
+
.ui-app--topbar > .ui-app__rail { padding-top: 0; }
|
|
333
|
+
.ui-app--topbar > .ui-app__rail > .ui-app__head { height: var(--ui-app-band); padding-bottom: 0; }
|
|
334
|
+
.ui-app--topbar > .ui-app__rail > .ui-app__foot { height: var(--ui-app-band); padding-top: 0; }
|
|
335
|
+
|
|
336
|
+
/* Drawn as a field, behaving as a button. The hairline and the radius are the
|
|
337
|
+
field's — src/styles/input.css:20 `.ui-input,` sets both — because what it looks
|
|
338
|
+
like is the point. The STEP is not, and that is the one thing this control does
|
|
339
|
+
differently from every other field in the kit: the reason is on the declaration
|
|
340
|
+
below. It gives way before anything else on the band.
|
|
341
|
+
why: docs/specification.md#the-page-shell */
|
|
342
|
+
.ui-app__search {
|
|
343
|
+
display: flex;
|
|
344
|
+
align-items: center;
|
|
345
|
+
gap: var(--space-2);
|
|
346
|
+
flex: 0 1 var(--panel-sm);
|
|
347
|
+
min-width: 0;
|
|
348
|
+
padding: 7px 10px;
|
|
349
|
+
/* A family alone leaves the browser's leading behind: `font: inherit` first, then
|
|
350
|
+
the one step this row takes. why: CONTRIBUTING.md#button-chrome-ledgers */
|
|
351
|
+
font: inherit;
|
|
352
|
+
font-size: var(--text-sm);
|
|
353
|
+
text-align: start;
|
|
354
|
+
color: var(--muted);
|
|
355
|
+
/* --surface, not the sunken --surface-2 every other field in the kit takes:
|
|
356
|
+
those sit on a card and this one sits on the band, and the band IS --bg, the
|
|
357
|
+
bottom of the ladder — so a sunken fill has nowhere to go but down into its
|
|
358
|
+
own ground. Measured in light, the field was 1.10:1 BELOW the band and its
|
|
359
|
+
key cap 1.04:1 against the field it sits in; they are 1.08 above and 1.14 now.
|
|
360
|
+
Dark trades the other way and is stated rather than hidden: the cap goes
|
|
361
|
+
1.29 -> 1.16, because --surface-3 and --surface are adjacent rungs there.
|
|
362
|
+
Artur chose this over three alternatives on #318; the survey of what seven
|
|
363
|
+
other systems do is docs/reviews/318-search-field.md.
|
|
364
|
+
why: docs/specification.md#elevation */
|
|
365
|
+
background: var(--surface);
|
|
366
|
+
border: 1px solid var(--border);
|
|
367
|
+
border-radius: var(--radius-md);
|
|
368
|
+
cursor: pointer;
|
|
369
|
+
transition: border-color var(--dur-med) var(--ease), color var(--dur-med) var(--ease);
|
|
370
|
+
}
|
|
371
|
+
.ui-app__search:hover { border-color: var(--border-strong); color: var(--strong); }
|
|
372
|
+
/* Every other control on the band takes the kit's ring. This one is a <button>
|
|
373
|
+
without `.ui-btn`, so base.css's shared rule never reached it and focus fell
|
|
374
|
+
through to the browser's own square outline, drawn around a 12px radius.
|
|
375
|
+
why: docs/specification.md#the-focus-ring */
|
|
376
|
+
.ui-app__search:focus-visible { outline: none; box-shadow: var(--ring); border-color: var(--accent); }
|
|
377
|
+
/* The same glyph the palette draws in its own search box, at the same box and the
|
|
378
|
+
same stroke: the trigger and the thing it opens are not two search marks.
|
|
379
|
+
src/styles/command-palette.css:107 `.ui-cmdk__search-ic svg` */
|
|
380
|
+
.ui-app__search-ic { display: flex; flex: none; }
|
|
381
|
+
.ui-app__search-ic svg { width: 17px; height: 17px; stroke: currentColor; fill: none; stroke-width: 2.2; }
|
|
382
|
+
/* Part of the button's name, so it is squeezed rather than dropped: an ellipsis
|
|
383
|
+
still leaves the whole string in the tree. */
|
|
384
|
+
.ui-app__search-txt { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
385
|
+
/* The other half of the name, so it never gives way. */
|
|
386
|
+
.ui-app__search kbd { flex: none; }
|
|
387
|
+
|
|
388
|
+
/* The mark alone — no room for two lines on a 52px row. No rule over it, since the
|
|
389
|
+
band's hairline is under the whole of it, and no column, since this is not the rail.
|
|
390
|
+
why: docs/specification.md#the-page-shell */
|
|
391
|
+
.ui-app__user--bar { flex: none; padding-top: 0; border-top: 0; }
|
|
392
|
+
.ui-app__user--bar .ui-app__av { margin-inline-start: 0; }
|
|
280
393
|
/* Crumb, then the title block. At --space-2 the crumb sat 8px off the h1 and
|
|
281
394
|
the h1 8px off its subtitle, so all three ranks read as one. */
|
|
282
395
|
.ui-app__main .ui-nav--crumbs { margin-bottom: var(--space-5); }
|
|
@@ -321,6 +434,11 @@
|
|
|
321
434
|
.ui-app { --ui-rail-w: calc(var(--ui-nav-strip) + 2 * var(--space-4) + var(--ui-rail-rule)); }
|
|
322
435
|
/* The strip is the only layout this width has, so there is nothing to fold. */
|
|
323
436
|
.ui-app__rail .ui-app__fold-row { display: none; }
|
|
437
|
+
/* …and the foot goes with it, for the reason the head band does below: in the
|
|
438
|
+
topbar layout that box holds the toggle and nothing else, so at this width it is
|
|
439
|
+
padding and a hairline drawn over nothing. Scoped to the shell's own rail, like
|
|
440
|
+
every other rule in this block. why: docs/specification.md#the-page-shell */
|
|
441
|
+
.ui-app__rail .ui-app__foot { display: none; }
|
|
324
442
|
/* …and with it the band, when the band has nothing else in it: a head with no
|
|
325
443
|
wordmark is the toggle alone, so at this width it is padding and a hairline
|
|
326
444
|
drawn over nothing. The reader's fold cannot take this rule — there the toggle
|
package/src/styles/topbar.css
CHANGED
|
@@ -180,7 +180,7 @@
|
|
|
180
180
|
transition: background var(--dur-fast) var(--ease);
|
|
181
181
|
}
|
|
182
182
|
/* The menu is raised, so a hovered row takes the step ABOVE it — see
|
|
183
|
-
src/styles/dropdown.css:
|
|
183
|
+
src/styles/dropdown.css:155 `.ui-dropdown__item:hover`.
|
|
184
184
|
why: docs/specification.md#elevation */
|
|
185
185
|
.vopt:hover { background: var(--surface-3); }
|
|
186
186
|
.vopt .vname { font-family: var(--font-sans); font-size: 12.5px; color: var(--strong); font-weight: 600; letter-spacing: 0.01em; }
|