@cssints/headless 0.15.0 → 0.18.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 +191 -1
- package/dist/index.d.mts +531 -1
- package/dist/index.mjs +850 -34
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -65,7 +65,8 @@ A primitive that has a seam honours an optional `prefs` entry (a `Partial<Prefs>
|
|
|
65
65
|
- **menu / menubar** — `typeaheadDelayMs` (the typeahead buffer timeout).
|
|
66
66
|
- **tooltip** — `tooltipShowDelayMs` / `tooltipHideDelayMs`, exposed as `showDelayMs()` / `hideDelayMs()` for the host to wire its `setTimeout` from (the spec keeps timers as events).
|
|
67
67
|
|
|
68
|
-
Everything else **ignores** prefs: accordion, alert, alertdialog, breadcrumb, button, carousel, checkbox, checkbox-group, combobox, confirm, dialog-modal, disclosure, feed, field, grid, link, listbox, meter, player, popover, radio, resizable-panels, slider, slider-multithumb, spinbutton, switch, tabs, timeline, toolbar, treegrid, treeview, windowsplitter — they expose no timing / motion / live-region / camera seam. `listbox` / `treeview` / `timeline` typeahead timeouts are host-driven via their reset methods, so they take no delay here either.
|
|
68
|
+
Everything else **ignores** prefs: accordion, alert, alertdialog, breadcrumb, button, carousel, checkbox, checkbox-group, combobox, confirm, dialog-modal, disclosure, feed, field, grid, link, listbox, meter, player, popover, radio, resizable-panels, scrollspy, slider, slider-multithumb, spinbutton, switch, table, tabs, timeline, toolbar, treegrid, treeview, windowsplitter — they expose no timing / motion / live-region / camera seam. `listbox` / `treeview` / `timeline` typeahead timeouts are host-driven via their reset methods, so they take no delay here either.
|
|
69
|
+
Everything else **ignores** prefs: accordion, alert, alertdialog, breadcrumb, button, carousel, checkbox, checkbox-group, combobox, confirm, dialog-modal, disclosure, feed, field, grid, link, listbox, meter, panzoom, player, popover, radio, resizable-panels, slider, slider-multithumb, spinbutton, switch, table, tabs, timeline, toolbar, treegrid, treeview, windowsplitter — they expose no timing / motion / live-region / camera seam. `listbox` / `treeview` / `timeline` typeahead timeouts are host-driven via their reset methods, so they take no delay here either.
|
|
69
70
|
|
|
70
71
|
`motionDurationMs` is **not consumed**: canvas tweens are tick-driven, not millisecond-driven, and the look layer owns durations. It stays in `Prefs` only so a host's preference record fits the type.
|
|
71
72
|
|
|
@@ -89,6 +90,41 @@ Membership, sizes and z-order are **not modelled** in `pointer.qnt` (its node se
|
|
|
89
90
|
|
|
90
91
|
**Nested canvases** are two instances the host composes: a `#canvas` node holds a second `createCanvas`, and the inner viewport stops propagation of its own pointer, wheel and key events. The innermost canvas under the pointer owns the event, so a press or zoom inside it never starts an outer gesture or moves the outer camera. A nested canvas is a single tab stop in its parent.
|
|
91
92
|
|
|
93
|
+
## Pan and zoom
|
|
94
|
+
|
|
95
|
+
`createPanZoom` is one image fitted to a viewport: the scale, the offset and the prop objects for the viewport, the image and a live value. **There is no APG pattern for pan-zoom**, so there is no Quint spec and no ported scenarios: the contract is this module's own and `test/panzoom.browser.test.tsx` is its oracle. The case it replaces (kocharin-art-3's `Zoomable`: ~110 lines of pointer maths) had no keyboard path and no ARIA at all.
|
|
96
|
+
|
|
97
|
+
**The accessible contract is three parts, and the pointer is a bonus:** a viewport that takes focus (`role=group`, `tabindex=0`, `aria-roledescription="zoomable image"`, `aria-describedby` the value), keyboard operations that stand in for every gesture (`+` / `=` / `-` zoom by `step`, arrows pan by 24 px, Shift ×4, `0` resets), and a polite live region (`role=status`, `aria-live=polite`, the current `"150%"`). `valueProps` deliberately carries no `hidden`: a `hidden` live region is not announced, so the host hides it visually.
|
|
98
|
+
|
|
99
|
+
**Not `createCanvas`.** The canvas is a camera over an infinite world with nodes, culling, snap and a z-order; a picture is one image in a box. There is no world, no node set and no reason to carry the camera machine.
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
const panzoom = createPanZoom({ min: 1, max: 4, step: 0.25 });
|
|
103
|
+
|
|
104
|
+
<div {...panzoom.viewportProps} class="viewport">
|
|
105
|
+
<img {...panzoom.imageProps} src={src} alt={alt} />
|
|
106
|
+
<span {...panzoom.valueProps} class="visually-hidden">
|
|
107
|
+
{panzoom.value()}
|
|
108
|
+
</span>
|
|
109
|
+
</div>;
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
**Gestures.** A drag pans, a second pointer pinches about its midpoint, a double click resets. Pinch is two pointers and the distance between them, not a platform gesture, so it works where `PointerEvent` does. **Wheel is zoom only with `ctrl` / `meta`** — the browser's own pinch arrives as `ctrl+wheel` — and a bare wheel is never `preventDefault`ed, so the page keeps scrolling; a swallowed wheel is the one thing that is never acceptable on a trackpad.
|
|
113
|
+
|
|
114
|
+
**`bounds: "clamp"`** (the default) bounds the offset by half the viewport's scaled overflow, so the image is expected to fill the viewport at `min` (`width: 100%; height: 100%` / `object-fit: cover`). A letterboxed image smaller than its viewport cannot be held gap-free: give it a viewport of its own or `bounds: "free"`.
|
|
115
|
+
|
|
116
|
+
Left to the host: the box (the viewport's size and `overflow: hidden`, `touch-action: none` if a touch drag should pan), the look (a transition on the transform, `transform-origin: 50% 50%` — the default this maths assumes), and the image itself. Not modelled: inertia, rotation, and a keyboard pan at scale `min` (the clamp means there is nothing to pan, so the arrows are inert).
|
|
117
|
+
|
|
118
|
+
```tsx
|
|
119
|
+
const panzoom = createPanZoom({
|
|
120
|
+
scale, // controlled: the host owns the scale
|
|
121
|
+
onScaleChange: setScale,
|
|
122
|
+
onPanChange: (offset) => save({ x: offset.x, y: offset.y }),
|
|
123
|
+
});
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Controlled is `scale` + `onScaleChange`; the offset has no controlled prop, so a host that ignores a scale request also gets the offset resolved for the scale it refused.
|
|
127
|
+
|
|
92
128
|
## Resizable panels
|
|
93
129
|
|
|
94
130
|
`createResizablePanels` is a group of N panes and N-1 separators: react-resizable-panels' layout on the APG window splitter's keyboard. Its behaviour is `packages/apg-specs/specs/patterns/resizable-panels.qnt` — not an APG pattern, so it lives under `patterns/` — and the DOM is checked against it by the differential walk. The spec is the three-pane instance (10/80 bounds, 10% steps, pane 0 collapsible at 0); the primitive takes N panes and any bounds.
|
|
@@ -189,6 +225,70 @@ const pages = createPagination({ page: 3, pageCount: 12, hrefFor: (page) => `/is
|
|
|
189
225
|
- **`hrefFor` is what makes it work without JS**: with it every page is a real link; without it they are buttons and the host owns navigation.
|
|
190
226
|
- **Target size is the skin's job**: 24x24 CSS px at AA, 44x44 at AAA.
|
|
191
227
|
|
|
228
|
+
## Table
|
|
229
|
+
|
|
230
|
+
`createTable` is sortable column headers and row selection on a **native `<table>`**: a `<caption>`, `<thead>`, `<tbody>`, `<th scope="col">` and `<td>` carry the semantics, so the primitive adds no `role` and no `aria-rowindex`, only what the elements cannot say themselves (`aria-sort` on the sorted column, `aria-selected` on a row, the mixed select-all state, and the totals a windowed table hides). It is a grid with a header row but **without the grid's navigation**: the cells keep their own focus, and `createGrid` stays the widget for roving cell focus. There is no spec of its own — it composes the two state models the package already has, `grid-sort.qnt` and `grid-selection.qnt` — so `test/table.browser.test.tsx` is its oracle.
|
|
231
|
+
|
|
232
|
+
```tsx
|
|
233
|
+
const table = createTable({
|
|
234
|
+
columns: [
|
|
235
|
+
{ key: "title", abbr: "Title" },
|
|
236
|
+
{ key: "status", sortable: false },
|
|
237
|
+
{ key: "count", numeric: true },
|
|
238
|
+
],
|
|
239
|
+
rows, // the host's ids, in the host's order: it sorts its data from the descriptor
|
|
240
|
+
selection: "multiple",
|
|
241
|
+
defaultSort: { key: "title", direction: "ascending" },
|
|
242
|
+
labels: { table: "Open issues", selectRow: (id) => `Select ${nameOf(id)}` },
|
|
243
|
+
onSortChange: (sort) => setSort(sort), // the host re-sorts its data and passes `rows` again
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
<table {...table.tableProps}>
|
|
247
|
+
<caption {...table.captionProps}>Open issues</caption>
|
|
248
|
+
<thead>
|
|
249
|
+
<tr>
|
|
250
|
+
{table.selectAllProps && (
|
|
251
|
+
<th>
|
|
252
|
+
<span {...table.selectAllProps} />
|
|
253
|
+
</th>
|
|
254
|
+
)}
|
|
255
|
+
{columns.map((column) => {
|
|
256
|
+
const sortButton = table.headerButtonProps(column.key); // undefined when not sortable
|
|
257
|
+
return (
|
|
258
|
+
<th {...table.headerCellProps(column.key)}>
|
|
259
|
+
{sortButton ? <button {...sortButton}>{column.key}</button> : column.key}
|
|
260
|
+
</th>
|
|
261
|
+
);
|
|
262
|
+
})}
|
|
263
|
+
</tr>
|
|
264
|
+
</thead>
|
|
265
|
+
<tbody>
|
|
266
|
+
{rows.map((id) => (
|
|
267
|
+
<tr {...table.rowProps(id)}>
|
|
268
|
+
{table.selectAllProps && (
|
|
269
|
+
<td>
|
|
270
|
+
<span {...table.rowCheckboxProps(id)} />
|
|
271
|
+
</td>
|
|
272
|
+
)}
|
|
273
|
+
{columns.map((column) => (
|
|
274
|
+
<td {...table.cellProps(id, column.key)}>{cellOf(id, column.key)}</td>
|
|
275
|
+
))}
|
|
276
|
+
</tr>
|
|
277
|
+
))}
|
|
278
|
+
</tbody>
|
|
279
|
+
</table>;
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
- **Sorting is the host's data.** The primitive carries the descriptor (`sort()`) and announces it; it never reorders anything. The host maps the descriptor onto its own array and renders `rows` again.
|
|
283
|
+
- **`aria-sort` is on the sorted column only.** A sortable header that is not sorted carries nothing (ARIA's default is "not sorted"), and a `sortable: false` column is fully inert: no `aria-sort`, and `headerButtonProps` is `undefined`, so the host renders no button and it is not a tab stop.
|
|
284
|
+
- **The cycle is tri-state by default** (`ascending` -> `descending` -> unsorted), so a table can be put back in its own order; `sortCycle: "toggle"` gives `ascending` <-> `descending` and never returns to unsorted, which is what the APG grid example does. A new column always starts `ascending`, and only one column is ever sorted.
|
|
285
|
+
- **Rows are ids, not a count.** The host sorts its data, so an id keeps naming the same row across a re-sort while an index would not; the primitive never learns the row's content.
|
|
286
|
+
- **Selection** is `"none"` (the default), `"single"` or `"multiple"`, over ids: `selected()` is the list, `rowProps(id)` carries `aria-selected` and a click that selects, and `toggleRow` / `toggleAll` are the programmatic path. The row's checkbox stops its own click from reaching the row, so the two controls never toggle the same row twice.
|
|
287
|
+
- **Select-all exists only for `"multiple"`** (`selectAllProps` and `rowCheckboxProps` are `undefined` otherwise: one control over one selection is meaningless). It is a `role="checkbox"`, not a native `input`: the mixed state of a native checkbox is a DOM property (`indeterminate`), and what a screen reader must hear is `aria-checked="mixed"`. It toggles the rows the table renders — a windowed table therefore toggles its window and keeps the rest of the selection.
|
|
288
|
+
- **Windowed rows or columns** pass `rowCount` / `columnCount`: then `aria-rowcount` and `aria-colcount` are carried, and every cell gets its `aria-colindex`. A table that renders all of them carries neither (it would only repeat what the markup says).
|
|
289
|
+
- **`numeric` is a layout hint** for the host's skin (right alignment, tabular figures): HTML has no attribute for it, so the primitive carries nothing.
|
|
290
|
+
- Solid 2 arrives through delegated events; a host that reads the DOM right after a click waits a tick.
|
|
291
|
+
|
|
192
292
|
## Confirm
|
|
193
293
|
|
|
194
294
|
`createConfirm` is a promise-based confirmation over `createDialogModal({ role: "alertdialog" })`. Like the field it is not checked against a spec (the dialog is; `test/confirm.browser.test.tsx` covers the promise around it, in Chromium).
|
|
@@ -296,6 +396,96 @@ onMount(() => {
|
|
|
296
396
|
- **Not a menu.** No roving focus, no arrow keys between items: it is the base for `createMenu` in a popover, and for the filterable menu, which own their own item focus.
|
|
297
397
|
- Absent on purpose: hover open (that is `createTooltipGroup`), submenus and item navigation (the menu), a scroll lock and `inert` (the modal dialog), and a vertical shift when neither side fits (the caller sees the requested placement).
|
|
298
398
|
|
|
399
|
+
## Drawer
|
|
400
|
+
|
|
401
|
+
`createDrawer` is the drawer / sheet shell: an edge-anchored panel over the Dialog (Modal) semantics, with the same focus, Escape, scroll-lock and backdrop behaviour as `createConfirm`. **No APG pattern exists for a drawer or a bottom sheet**, and [docs/bottom-sheet.md](docs/bottom-sheet.md) settled that the library composes rather than adding a sheet primitive: the semantics are Dialog (Modal), the position, the slide and the snap points are the host's native CSS, and this is the panel both were missing. `test/drawer.browser.test.tsx` is the oracle.
|
|
402
|
+
|
|
403
|
+
```tsx
|
|
404
|
+
const drawer = createDrawer({ side: "right", labelledby: "nav-title" });
|
|
405
|
+
|
|
406
|
+
<button {...drawer.triggerProps}>Menu</button>;
|
|
407
|
+
<div {...drawer.backdropProps} />;
|
|
408
|
+
<div {...drawer.contentProps}>
|
|
409
|
+
<h2 id="nav-title">Site</h2>
|
|
410
|
+
<nav>{/* links */}</nav>
|
|
411
|
+
</div>;
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
- **`side` is presentation only**, `"right"` (the default), `"left"` or `"bottom"`: a sheet is `{ side: "bottom" }`, a side rail is `{ side: "left" }`, and it is one API either way. The primitive lays out nothing; the host's CSS anchors the panel, and `handleProps` is the grab handle of a sheet.
|
|
415
|
+
- **`modal` is the breakpoint switch.** It is read on every attribute read, so a host may flip it while the drawer is open (crossing the breakpoint) and `aria-modal`, the backdrop and the lock follow. `breakpoint` is a px number the primitive **does not read**: it has no viewport, so the host watches `matchMedia` and passes `side` and `modal`. A sheet below a breakpoint is modal; a side rail above it may not be.
|
|
416
|
+
- **Modal** (the default): `aria-modal="true"`, a backdrop, focus into the panel's first focusable on open (else the panel, which is `tabindex="-1"`) and back to the trigger on close, Tab and Shift+Tab cycling inside, `overflow: hidden` on `document.documentElement` counted (as in `createConfirm`, and shared with it: a confirm over a drawer nests), and Escape read from the document in the capture phase, so it closes wherever the focus is. **Non-modal**: no `aria-modal`, no backdrop, no lock and no Tab trap, so the page keeps its scroll and its clicks; an outside `pointerdown` still dismisses it when `dismissable`, and Escape still closes it.
|
|
417
|
+
- **The trigger toggles** (a hamburger), and Enter / Space open it. `dismissable: false` keeps the backdrop and an outside `pointerdown` from closing the drawer; Escape still closes it, because there is no way out otherwise.
|
|
418
|
+
- **No drag-to-dismiss.** A drag is not a dialog interaction: it is a scroll snap the platform runs off the main thread, and it is the host's CSS (`scroll-snap-type: y mandatory`, snap markers, `scrollsnapchange`; see [docs/bottom-sheet.md](docs/bottom-sheet.md)). Re-implementing a native snap in pointer events would be the gesture duplication that research forbids, and the handle would drag on top of the snap that already moved the panel. So `handleProps` is decoration (`aria-hidden`, no pointer handlers) and the ways out are the backdrop, Escape, a close control the host puts in the panel, and the trigger. Swipe-to-dismiss, if it is ever wanted, is a separate decision with its own oracle.
|
|
419
|
+
- **No `attach`.** The panel is edge-anchored: nothing is measured and no placement is computed, so focus and the Tab cycle find the panel through `contentProps.id` and the host only spreads attributes onto its own elements. Give the panel a name: pass `labelledby`, or set `aria-labelledby` / `aria-label` after the spread.
|
|
420
|
+
|
|
421
|
+
## Hotkeys
|
|
422
|
+
|
|
423
|
+
`createHotkeys` is a **platform utility, not a widget**: one registry for the chords every app hand-rolls (the docs app's `/` and Cmd/Ctrl+K, the CMS's Cmd/Ctrl+S). **No APG pattern exists for a keyboard registry**, so there is no role, no element, no styling and no Quint spec; `test/hotkeys.browser.test.tsx` is the oracle.
|
|
424
|
+
|
|
425
|
+
```tsx
|
|
426
|
+
const hotkeys = createHotkeys();
|
|
427
|
+
|
|
428
|
+
onSettled(() => {
|
|
429
|
+
hotkeys.bind({
|
|
430
|
+
keys: "mod+k",
|
|
431
|
+
onTrigger: () => searchEl.focus(),
|
|
432
|
+
});
|
|
433
|
+
hotkeys.bind({
|
|
434
|
+
keys: "/",
|
|
435
|
+
when: () => !dialog().open,
|
|
436
|
+
onTrigger: () => searchEl.focus(),
|
|
437
|
+
});
|
|
438
|
+
hotkeys.bind({
|
|
439
|
+
keys: "mod+s",
|
|
440
|
+
scope: editorEl,
|
|
441
|
+
onTrigger: () => save(),
|
|
442
|
+
});
|
|
443
|
+
return () => hotkeys.dispose();
|
|
444
|
+
});
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
- **One listener.** `bind` adds a chord to one `keydown` listener on the document in the capture phase (a `scope` only filters the event's target, it does not add a listener), and the last binding to go is the one that removes it; `dispose` unbinds everything and a later `bind` is a no-op. The unbind `bind` returns is idempotent.
|
|
448
|
+
- **Modifiers match exactly.** `mod` is Cmd on macOS and Ctrl elsewhere (`isApple` injects the answer, so a test does not read the runner's OS); `ctrl`, `alt`, `shift` and `meta` are as written. A chord that does not declare Shift does not fire with Shift held. A shifted character implies Shift: `"?"` fires from Shift+/ even without `"shift+?"`, and `"/"` does not fire from it.
|
|
449
|
+
- **A text field keeps its keys.** A binding does not fire while the focus is in an `input`, a `textarea` or a `contenteditable` (an inherited one included) unless it sets `allowInEditable`, which is what Escape wants: it closes a dialog wherever the focus is.
|
|
450
|
+
- **Last bound wins.** Only the most recently bound match fires, so a modal's `"/"` shadows the page's and unbinding it brings the page's back; a `when` that is false makes the next match fire instead, so the gate can close without unbinding.
|
|
451
|
+
- **`preventDefault` is on** for a binding that fires (Cmd/Ctrl+S and Cmd/Ctrl+K would otherwise reach the browser); pass `false` to let the platform act too.
|
|
452
|
+
|
|
453
|
+
Collisions with the browser's own chords: `preventDefault` reaches the page's default action, not the browser's. Cmd/Ctrl+S (Save Page), Cmd/Ctrl+K (the browser's search, in Chrome and Firefox), Cmd/Ctrl+P and `/` (Firefox's quick find) can be cancelled by a page, and a host that binds one is choosing to take it from the browser; Cmd/Ctrl+T, Cmd/Ctrl+W, Cmd/Ctrl+N, Cmd/Ctrl+L and F11 are reserved and cannot be intercepted, so a chord built on them never fires. `keys` takes any of them the same and says nothing.
|
|
454
|
+
|
|
455
|
+
## Scrollspy
|
|
456
|
+
|
|
457
|
+
`createScrollspy` is a table of contents whose current entry is the last heading to reach the top of the scrollport. **No APG pattern exists for a scrollspy** (the APG has no table-of-contents pattern), so there is no Quint spec and no ported scenarios: the contract is WAI-ARIA — `aria-current="location"` on the current link, exactly one link at a time, `role="list"` / `role="listitem"` for the list, and a named navigation landmark — plus HTML (`#id` targets). `test/scrollspy.browser.test.tsx` is its oracle.
|
|
458
|
+
|
|
459
|
+
```tsx
|
|
460
|
+
const toc = createScrollspy({
|
|
461
|
+
entries, // { id, label } in document order: the ids are the headings'
|
|
462
|
+
offset: 96, // a sticky header's height
|
|
463
|
+
onChange: (id) => track(id),
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
onSettled(() => toc.attach()); // returns the teardown
|
|
467
|
+
|
|
468
|
+
<nav {...toc.navProps}>
|
|
469
|
+
<ul {...toc.listProps}>
|
|
470
|
+
<For each={entries}>
|
|
471
|
+
{(entry) => (
|
|
472
|
+
<li {...toc.itemProps(entry.id)}>
|
|
473
|
+
<a {...toc.linkProps(entry.id)}>{entry.label}</a>
|
|
474
|
+
</li>
|
|
475
|
+
)}
|
|
476
|
+
</For>
|
|
477
|
+
</ul>
|
|
478
|
+
</nav>;
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
- **A scroll listener, not an `IntersectionObserver`.** "The last heading at or above the offset" is a total order over every heading, and an observer only reports what crossed its band: a band observer has to rebuild the order from its enter/leave state and misreports after a jump (an anchor click, a restored scroll position). The listener is passive, the measurements are reads only (no writes, so no layout thrash), and a docs page has a dozen headings — the same cost as the listener the page already keeps.
|
|
482
|
+
- **`attach()`** adds that one listener to `root()` (the document by default; read once, so attach again after swapping containers), calls it once to sync a page opened mid-scroll, and returns the teardown. Headings are looked up by id on every scroll, so they may render after `attach`; an id with no element is skipped.
|
|
483
|
+
- **`offset`** is px below the scrollport's top edge, not the viewport's, so an inner container plus a sticky header works: `offset` is the header's height inside that container.
|
|
484
|
+
- **Before any scroll the first entry is current**, and it stays current while nothing has passed the line. `active()` is `undefined` only when there are no entries.
|
|
485
|
+
- **Exactly one `aria-current="location"`**, on the link, never on the heading: the heading's own `id` is the target. Skin it with `a[aria-current="location"]`.
|
|
486
|
+
- **Smooth scrolling is the host's.** The links are plain `#id` targets, the primitive never moves the scrollport, so `scroll-behavior: smooth` (and any `prefers-reduced-motion` override) belongs to the page's CSS; there is no scroll helper to reduce.
|
|
487
|
+
- **"On this page" is English.** Spread `navProps` first and override it after: `<nav {...toc.navProps} aria-label={t("onThisPage")}>`.
|
|
488
|
+
|
|
299
489
|
## Commands
|
|
300
490
|
|
|
301
491
|
```sh
|