@blakesteve/roster 5.12.0 → 5.13.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 +101 -9
- package/dist/components/molecules/LiquidTabs/LiquidCount.cjs +1 -0
- package/dist/components/molecules/LiquidTabs/LiquidCount.d.ts +29 -0
- package/dist/components/molecules/LiquidTabs/LiquidCount.js +21 -0
- package/dist/components/molecules/LiquidTabs/LiquidNav.cjs +1 -1
- package/dist/components/molecules/LiquidTabs/LiquidNav.d.ts +12 -1
- package/dist/components/molecules/LiquidTabs/LiquidNav.js +50 -44
- package/dist/components/molecules/LiquidTabs/LiquidTabPanel.cjs +1 -0
- package/dist/components/molecules/LiquidTabs/LiquidTabPanel.d.ts +52 -0
- package/dist/components/molecules/LiquidTabs/LiquidTabPanel.js +32 -0
- package/dist/components/molecules/LiquidTabs/LiquidTabs.cjs +1 -1
- package/dist/components/molecules/LiquidTabs/LiquidTabs.d.ts +20 -1
- package/dist/components/molecules/LiquidTabs/LiquidTabs.js +60 -55
- package/dist/components/molecules/LiquidTabs/liquid-count.cjs +1 -0
- package/dist/components/molecules/LiquidTabs/liquid-count.d.ts +37 -0
- package/dist/components/molecules/LiquidTabs/liquid-count.js +9 -0
- package/dist/components/organisms/ModerationQueue/ModerationQueue.cjs +1 -1
- package/dist/components/organisms/ModerationQueue/ModerationQueue.js +214 -193
- package/dist/index.d.ts +2 -0
- package/dist/internal/key-focus.d.ts +27 -2
- package/dist/meta.json +3 -3
- package/dist/roster.cjs +1 -1
- package/dist/roster.css +1 -1
- package/dist/roster.js +150 -145
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -561,7 +561,7 @@ element marked `data-key-focus`. WebKit doesn't match `:focus-visible` on an
|
|
|
561
561
|
element a script focuses in answer to a key once the page has been clicked.
|
|
562
562
|
Escape handing focus back to a menu's or a select's trigger, or to a dialog's
|
|
563
563
|
opener, and a radio group's or tabs' arrows all drew no ring in Safari after
|
|
564
|
-
a click. A small
|
|
564
|
+
a click. A small tracker marks those elements: keyboard mode starts on a navigation key (Tab, Option+Tab, the
|
|
565
565
|
arrows, Escape, Enter, Space, Home, End, PageUp, PageDown) and ends on pointer
|
|
566
566
|
input, and it marks only what Chromium's own `:focus-visible` already matches,
|
|
567
567
|
so Chromium's rings are unchanged. It runs while a component that moves focus
|
|
@@ -576,6 +576,23 @@ variant in your stylesheet as Roster does:
|
|
|
576
576
|
@custom-variant focus-visible (&:is(:focus-visible, [data-key-focus]));
|
|
577
577
|
```
|
|
578
578
|
|
|
579
|
+
The variant is enough for your rings on a page where one of Roster's
|
|
580
|
+
components has the tracker running. A control of your own that moves focus by
|
|
581
|
+
script in answer to a key, arrows around a hand-drawn wheel or a grid that
|
|
582
|
+
roves its own focus, shouldn't count on one: call `useKeyFocus()` in it, and
|
|
583
|
+
the tracker runs while it's mounted. The listeners are shared, so it costs
|
|
584
|
+
nothing beside Roster's own components. `KEY_FOCUS` is the attribute's name,
|
|
585
|
+
for a selector or a test.
|
|
586
|
+
|
|
587
|
+
```tsx
|
|
588
|
+
import { useKeyFocus } from "@blakesteve/roster";
|
|
589
|
+
|
|
590
|
+
function Wheel() {
|
|
591
|
+
useKeyFocus();
|
|
592
|
+
// arrows move focus between the stops with stop.focus(); rings on focus-visible:
|
|
593
|
+
}
|
|
594
|
+
```
|
|
595
|
+
|
|
579
596
|
### Motion
|
|
580
597
|
|
|
581
598
|
Two hand-rolled utilities, both of which stop for `prefers-reduced-motion`.
|
|
@@ -1352,6 +1369,10 @@ What it promises:
|
|
|
1352
1369
|
item's decision.
|
|
1353
1370
|
- **Steps take focus and give it back.** Escape or Cancel returns focus to the
|
|
1354
1371
|
button that opened the step.
|
|
1372
|
+
- **A blocked step says why, plainly.** A step that can't be submitted yet (a
|
|
1373
|
+
reason not picked, an edit with nothing changed) says why in plain text,
|
|
1374
|
+
and its submit is described by it. A submit tried anyway decides nothing
|
|
1375
|
+
and turns the reason into an error.
|
|
1355
1376
|
- **An item that disappears** (another reviewer, a reload) with its step open
|
|
1356
1377
|
closes the step without deciding, and is announced once as decided
|
|
1357
1378
|
elsewhere. Without a step, it moves focus only if focus was inside it.
|
|
@@ -1360,10 +1381,11 @@ What it promises:
|
|
|
1360
1381
|
- **Phones.** Every button is 44px tall at every width, and nothing scrolls
|
|
1361
1382
|
sideways at 320px. Nothing animates as an item leaves.
|
|
1362
1383
|
|
|
1363
|
-
Counts for a tab
|
|
1364
|
-
`items.length
|
|
1365
|
-
image can't render (an expired link, a
|
|
1366
|
-
falls back to a link to the original in the
|
|
1384
|
+
Counts for a tab need no API: the app holds the items, so it's
|
|
1385
|
+
`items.length`, passed as a `LiquidTabs` tab's `count`. `MediaPreview` is the
|
|
1386
|
+
thumbnail to use in an item: when the image can't render (an expired link, a
|
|
1387
|
+
format the browser can't draw), it falls back to a link to the original in the
|
|
1388
|
+
same footprint.
|
|
1367
1389
|
|
|
1368
1390
|
### FileUpload
|
|
1369
1391
|
|
|
@@ -1878,6 +1900,71 @@ contained. Under reduced motion every scroll the component makes is instant.
|
|
|
1878
1900
|
`showPosition` shows "2 of 5", which is also each item's name and is announced
|
|
1879
1901
|
politely when it changes.
|
|
1880
1902
|
|
|
1903
|
+
### LiquidTabs
|
|
1904
|
+
|
|
1905
|
+
A tab strip with a sliding pill, on the WAI tabs pattern: one tab stop, arrows
|
|
1906
|
+
that wrap, Home and End, and `activation="manual"` for tabs that are costly to
|
|
1907
|
+
switch. `LiquidNav` is the same strip as a `nav` of links.
|
|
1908
|
+
|
|
1909
|
+
**Counts.** A tab or a nav link takes `count`, shown in a small mark after its
|
|
1910
|
+
label:
|
|
1911
|
+
|
|
1912
|
+
```tsx
|
|
1913
|
+
<LiquidTabs tabs={[{ id: "queue", label: "Queue", count: queue.length }, …]} … />
|
|
1914
|
+
```
|
|
1915
|
+
|
|
1916
|
+
The count is part of the name, "Queue, 12 items" rather than "Queue12" or
|
|
1917
|
+
"Queue (12)", and its words come from `labels`, English by default with a
|
|
1918
|
+
plural. Name your own noun, and use `Intl.PluralRules` for a language with
|
|
1919
|
+
more than two forms:
|
|
1920
|
+
|
|
1921
|
+
```tsx
|
|
1922
|
+
labels={{ count: (n) => (n === 1 ? "1 game" : `${n.toLocaleString()} games`) }}
|
|
1923
|
+
```
|
|
1924
|
+
|
|
1925
|
+
- **Zero shows,** as "0" and "Queue, 0 items": an empty queue is worth
|
|
1926
|
+
saying. To show no count, pass `undefined`.
|
|
1927
|
+
- **A count that isn't a whole number from zero** (negative, a fraction,
|
|
1928
|
+
`NaN`) shows nothing, as if there were none, rather than being rounded or
|
|
1929
|
+
clamped into a number nobody passed.
|
|
1930
|
+
- **Large counts are written whole,** "12,345", in the mark and the name. To
|
|
1931
|
+
shorten the mark, pass `labels.countMark` (`(n) => "12K"`); the name stays
|
|
1932
|
+
whole, since a screen reader has no width to save.
|
|
1933
|
+
- **The mark is drawn from the tab's own text color:** its number in that
|
|
1934
|
+
color on a 12% tint of it. So it follows the tab through every state, both
|
|
1935
|
+
variants and both themes, and through any palette set on the
|
|
1936
|
+
`--roster-lt-*` tokens, and its number clears 4.5:1 in every state on
|
|
1937
|
+
Roster's own (4.88:1 at the least). `Badge` and `Pill` each draw from a fixed
|
|
1938
|
+
palette, which on the selected tab would be a second chip sitting on the
|
|
1939
|
+
pill.
|
|
1940
|
+
- **The pill follows,** re-measuring when a count widens its tab or one
|
|
1941
|
+
before it.
|
|
1942
|
+
|
|
1943
|
+
**The panel.** `LiquidTabPanel` is the `role="tabpanel"` a tab controls, paired
|
|
1944
|
+
with it by construction: its `id` is the tab's `panelId`, and its
|
|
1945
|
+
`aria-labelledby` points at the tab through `liquidTabId`, so the panel takes
|
|
1946
|
+
the tab's name, count included. Give the `LiquidTabs` an `id` to pair it with.
|
|
1947
|
+
One panel that swaps its content, or a panel per tab:
|
|
1948
|
+
|
|
1949
|
+
```tsx
|
|
1950
|
+
// One panel: every tab carries the same panelId, and `tab` is the selected one.
|
|
1951
|
+
<LiquidTabs id="lists" tabs={tabs.map((t) => ({ ...t, panelId: "lists-panel" }))} activeTab={list} onChange={setList} />
|
|
1952
|
+
<LiquidTabPanel tabsId="lists" id="lists-panel" tab={list}>…</LiquidTabPanel>
|
|
1953
|
+
|
|
1954
|
+
// A panel per tab: each its own panelId, and every panel gets activeTab.
|
|
1955
|
+
<LiquidTabPanel tabsId="views" id="views-board" tab="board" activeTab={view}>…</LiquidTabPanel>
|
|
1956
|
+
<LiquidTabPanel tabsId="views" id="views-list" tab="list" activeTab={view} keepMounted>…</LiquidTabPanel>
|
|
1957
|
+
```
|
|
1958
|
+
|
|
1959
|
+
Per tab, a panel that isn't selected stays in the page, hidden, so every tab's
|
|
1960
|
+
`aria-controls` points at something, and renders no content. `keepMounted`
|
|
1961
|
+
keeps its content mounted, so a half-filled form or a scroll position survives
|
|
1962
|
+
a switch away and back. A panel with nothing inside it to Tab to is itself in
|
|
1963
|
+
the tab order (`tabIndex={0}`), as the WAI pattern asks, so a keyboard user
|
|
1964
|
+
can reach what they just selected; it leaves the tab order once its content
|
|
1965
|
+
holds a link, a button or a field, rechecked as the content changes. Pass
|
|
1966
|
+
`tabIndex` to decide it yourself.
|
|
1967
|
+
|
|
1881
1968
|
### Components that render links
|
|
1882
1969
|
|
|
1883
1970
|
`Breadcrumbs` renders a plain `<a>` by default, which is right for a static
|
|
@@ -1899,8 +1986,10 @@ once, and the pages that use the wrapper stay server-rendered.
|
|
|
1899
1986
|
`LiquidNav` takes a `linkComponent` too, with a wider contract: each link
|
|
1900
1987
|
receives `href` and `to` with the same value, so React Router's `Link` works as
|
|
1901
1988
|
well as `next/link`, plus `className`, `children`, `aria-current`, `onClick` and
|
|
1902
|
-
`data-tab-id`, which it must forward to the DOM
|
|
1903
|
-
|
|
1989
|
+
`data-tab-id`, which it must forward to the DOM, and `aria-label` when the
|
|
1990
|
+
link has a count (forward it too; a component that doesn't still gets a name
|
|
1991
|
+
with the count in it). The props type is exported as `LiquidNavLinkProps`. The
|
|
1992
|
+
same client-component rule applies.
|
|
1904
1993
|
|
|
1905
1994
|
`Eyebrow` takes the other approach: `as` accepts any element and the props
|
|
1906
1995
|
follow it, so `<Eyebrow as="a" href="/work">` works, as does `as={NextLink}`.
|
|
@@ -1926,7 +2015,8 @@ Dialog or Sheet), alongside `Pagination`, the loading system and DataTable's
|
|
|
1926
2015
|
instead, and 5.8.0 adds the ones that were missing: MultiSelect's
|
|
1927
2016
|
`countLabel`, DataTable's `emptyMessage` and MediaPreview's `newTabLabel`, next
|
|
1928
2017
|
to the existing `dismissLabel`, `placeholder`, `removeLabel`, `prevLabel` and
|
|
1929
|
-
the like.
|
|
2018
|
+
the like. `LiquidTabs` and `LiquidNav` take `labels` for the words a count
|
|
2019
|
+
says. Words that take a number or a name are functions, so a language
|
|
1930
2020
|
whose word changes with the count can say so. An entry passed as `undefined`
|
|
1931
2021
|
(a catalog missing a key) keeps its English rather than leaving a control
|
|
1932
2022
|
unnamed.
|
|
@@ -2038,7 +2128,8 @@ function App() {
|
|
|
2038
2128
|
| `EmptyState` | Zero-data placeholder with icon and action slot; `headingLevel` sets its title's `h1` to `h6` |
|
|
2039
2129
|
| `ErrorState` | Error display with retry action |
|
|
2040
2130
|
| `LiquidNav` | `LiquidTabs`' strip as a `nav` of links; takes a router's link component |
|
|
2041
|
-
| `LiquidTabs` | Tab strip with a sliding pill; arrow keys, roving focus, 44px `lg` size
|
|
2131
|
+
| `LiquidTabs` | Tab strip with a sliding pill; arrow keys, roving focus, counts, 44px `lg` size (see [LiquidTabs](#liquidtabs)) |
|
|
2132
|
+
| `LiquidTabPanel` | The panel a `LiquidTabs` tab controls, named for its tab by construction; one swapping panel or one per tab |
|
|
2042
2133
|
| `MatchupCard` | Head-to-head comparison card |
|
|
2043
2134
|
| `MediaPreview` | Thumbnail that falls back to "Preview unavailable, open original" when it can't render |
|
|
2044
2135
|
| `Menu` | A button's menu of actions: icons, shortcuts, checkbox and radio items, links, groups, submenus; the full keyboard pattern, 44px targets (see [Menu](#menu)) |
|
|
@@ -2125,6 +2216,7 @@ the one that can not be affected by a future change to the barrel.
|
|
|
2125
2216
|
| Hook | Description |
|
|
2126
2217
|
| ---------------- | ------------------------------------------------------------------------------- |
|
|
2127
2218
|
| `useCountdown` | Countdown timer logic without the UI; `days` counts the whole remaining total |
|
|
2219
|
+
| `useKeyFocus` | Keeps keyboard focus ringed in Safari for your own control that moves focus by script (see [Focus](#focus)). Ships `KEY_FOCUS` |
|
|
2128
2220
|
| `useKeySequence` | Fires a callback when a sequence of keys is typed in order. Ships `KONAMI_CODE` |
|
|
2129
2221
|
|
|
2130
2222
|
## Authoring components: the `rst:` prefix
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use client";"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const t=require("react/jsx-runtime"),n=require("./liquid-count.cjs");function i({label:s,count:r,labels:e}){return n.shownCount(r)?t.jsxs("span",{className:"rst:inline-flex rst:items-center rst:justify-center rst:gap-1.5",children:[s,t.jsx("span",{"aria-hidden":"true","data-tab-count":"",className:"rst:inline-flex rst:h-5 rst:min-w-5 rst:items-center rst:justify-center rst:rounded-full rst:bg-current/12 rst:px-1.5 rst:text-xs rst:font-semibold rst:leading-none rst:tabular-nums",children:e.countMark(r)}),t.jsx("span",{className:"rst:sr-only",children:`, ${e.count(r)}`})]}):t.jsx(t.Fragment,{children:s})}exports.LiquidCount=i;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
import { LiquidTabsLabels } from './liquid-count';
|
|
3
|
+
/**
|
|
4
|
+
* A tab's or a link's label, with its count beside it when it has one.
|
|
5
|
+
*
|
|
6
|
+
* The mark is drawn from the item's own text color: its number in that color,
|
|
7
|
+
* its fill a light tint of it. So it follows the item through every state it
|
|
8
|
+
* already styles (at rest, selected on the pill, hovered, focused), in both
|
|
9
|
+
* themes and both variants, and through any palette a consumer sets on the
|
|
10
|
+
* `--roster-lt-*` tokens. Badge and Pill each draw from a fixed palette, which
|
|
11
|
+
* on the selected tab would be a gray or brand chip sitting on the pill.
|
|
12
|
+
*
|
|
13
|
+
* The tint is 12% of the ink: enough to read as a mark, and light enough
|
|
14
|
+
* that the number still clears 4.5:1 against it in every state on Roster's
|
|
15
|
+
* own tokens (4.88:1 at the least, the selected tab and the dark strip at
|
|
16
|
+
* rest), where 15% left one state at 4.61.
|
|
17
|
+
*
|
|
18
|
+
* The mark is hidden from assistive technology. The count is said by the
|
|
19
|
+
* item's `aria-label` when its label is a string (`countName`), and by text
|
|
20
|
+
* hidden beside the label otherwise, so the name is "Open, 12 items" rather
|
|
21
|
+
* than "Open12" either way. The hidden text stays under an `aria-label` too,
|
|
22
|
+
* for a link component that doesn't forward one. Render it only for a count that shows (`shownCount`): with
|
|
23
|
+
* none, the item takes its label as it always has, wrapper and all.
|
|
24
|
+
*/
|
|
25
|
+
export declare function LiquidCount({ label, count, labels }: {
|
|
26
|
+
label: ReactNode;
|
|
27
|
+
count: number | undefined;
|
|
28
|
+
labels: LiquidTabsLabels;
|
|
29
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as r, Fragment as e, jsxs as i } from "react/jsx-runtime";
|
|
3
|
+
import { shownCount as a } from "./liquid-count.js";
|
|
4
|
+
function o({ label: s, count: t, labels: n }) {
|
|
5
|
+
return a(t) ? /* @__PURE__ */ i("span", { className: "rst:inline-flex rst:items-center rst:justify-center rst:gap-1.5", children: [
|
|
6
|
+
s,
|
|
7
|
+
/* @__PURE__ */ r(
|
|
8
|
+
"span",
|
|
9
|
+
{
|
|
10
|
+
"aria-hidden": "true",
|
|
11
|
+
"data-tab-count": "",
|
|
12
|
+
className: "rst:inline-flex rst:h-5 rst:min-w-5 rst:items-center rst:justify-center rst:rounded-full rst:bg-current/12 rst:px-1.5 rst:text-xs rst:font-semibold rst:leading-none rst:tabular-nums",
|
|
13
|
+
children: n.countMark(t)
|
|
14
|
+
}
|
|
15
|
+
),
|
|
16
|
+
/* @__PURE__ */ r("span", { className: "rst:sr-only", children: `, ${n.count(t)}` })
|
|
17
|
+
] }) : /* @__PURE__ */ r(e, { children: s });
|
|
18
|
+
}
|
|
19
|
+
export {
|
|
20
|
+
o as LiquidCount
|
|
21
|
+
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
"use client";"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const
|
|
1
|
+
"use client";"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const t=require("react/jsx-runtime"),N=require("react"),a=require("./liquid-pill.cjs"),x=require("./LiquidCount.cjs"),f=require("./liquid-count.cjs"),P=require("../../../lib/labels.cjs"),R=require("../../../lib/utils.cjs");function S({href:r,className:s,children:d,onClick:c,"aria-current":i,"aria-label":n,"data-tab-id":o}){return t.jsx("a",{href:r,className:s,onClick:c,"aria-current":i,"aria-label":n,"data-tab-id":o,children:d})}function _({items:r,activeTab:s,onChange:d,linkComponent:c,variant:i="pill",fullWidth:n=!1,size:o="md","aria-label":j,"aria-labelledby":p,className:C,labels:I}){const q=P.withLabels(f.LIQUID_COUNT_LABELS,I),L=N.useRef(null),y=N.useRef(null),h=c??S;return a.useLiquidPill(L,y,s,r.map(e=>e.id).join("\0")),t.jsxs("nav",{ref:L,"aria-label":j,"aria-labelledby":p,"data-testid":"liquid-nav",style:a.LIQUID_STRIP_STYLE,className:a.liquidStripClassName(i,n,C),children:[t.jsx("div",{ref:y,"aria-hidden":!0,"data-testid":"liquid-tabs-pill",className:a.liquidPillClassName(i),style:a.LIQUID_PILL_STYLE}),r.map(e=>{const u=e.id===s,b=typeof e.label=="function"?e.label(u):e.label;return t.jsx(h,{href:e.href,to:e.href,"aria-current":u?"page":void 0,"aria-label":f.countName(b,e.count,q),"data-tab-id":e.id,onClick:l=>{l.defaultPrevented||l.button!==0||l.metaKey||l.ctrlKey||l.shiftKey||l.altKey||u||d?.(e.id)},className:R.cn(a.liquidItemClassName({variant:i,fullWidth:n,size:o,isActive:u}),"rst:inline-flex rst:items-center rst:justify-center rst:no-underline"),children:f.shownCount(e.count)?t.jsx(x.LiquidCount,{label:b,count:e.count,labels:q}):b},e.id)})]})}exports.LiquidNav=_;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ComponentType, MouseEvent, ReactNode } from 'react';
|
|
2
2
|
import { LiquidSize, LiquidVariant } from './liquid-pill';
|
|
3
3
|
import { TabItem } from './LiquidTabs';
|
|
4
|
+
import { LiquidTabsLabels } from './liquid-count';
|
|
4
5
|
export interface LiquidNavItem extends Omit<TabItem, "panelId"> {
|
|
5
6
|
href: string;
|
|
6
7
|
}
|
|
@@ -19,6 +20,12 @@ export interface LiquidNavLinkProps {
|
|
|
19
20
|
className: string;
|
|
20
21
|
children: ReactNode;
|
|
21
22
|
"aria-current"?: "page";
|
|
23
|
+
/**
|
|
24
|
+
* The link's whole name when it has a count and a string label: "Inbox, 3
|
|
25
|
+
* items". Forward it to the `<a>`; a component that doesn't still gets a
|
|
26
|
+
* name with the count in it, from text hidden beside the label.
|
|
27
|
+
*/
|
|
28
|
+
"aria-label"?: string;
|
|
22
29
|
onClick: (event: MouseEvent<HTMLAnchorElement>) => void;
|
|
23
30
|
"data-tab-id": string;
|
|
24
31
|
}
|
|
@@ -49,6 +56,8 @@ export interface LiquidNavProps {
|
|
|
49
56
|
"aria-label"?: string;
|
|
50
57
|
"aria-labelledby"?: string;
|
|
51
58
|
className?: string;
|
|
59
|
+
/** Every word a count says, with English defaults: see `TabItem.count`. */
|
|
60
|
+
labels?: Partial<LiquidTabsLabels>;
|
|
52
61
|
}
|
|
53
62
|
/**
|
|
54
63
|
* `LiquidTabs`' look, for navigation between routes: a `nav` of links rather
|
|
@@ -58,5 +67,7 @@ export interface LiquidNavProps {
|
|
|
58
67
|
* things to assistive technology. Tabs switch panels on the same page and take
|
|
59
68
|
* one tab stop with arrow keys between them; links go somewhere, and each is
|
|
60
69
|
* its own tab stop, as in any nav.
|
|
70
|
+
*
|
|
71
|
+
* An item can carry a `count`, as a tab does: see `TabItem.count`.
|
|
61
72
|
*/
|
|
62
|
-
export declare function LiquidNav({ items, activeTab, onChange, linkComponent, variant, fullWidth, size, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, className, }: LiquidNavProps): import("react/jsx-runtime").JSX.Element;
|
|
73
|
+
export declare function LiquidNav({ items, activeTab, onChange, linkComponent, variant, fullWidth, size, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, className, labels, }: LiquidNavProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,70 +1,76 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsxs as q, jsx as
|
|
3
|
-
import { useRef as
|
|
4
|
-
import { useLiquidPill as
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
2
|
+
import { jsxs as q, jsx as o } from "react/jsx-runtime";
|
|
3
|
+
import { useRef as m } from "react";
|
|
4
|
+
import { useLiquidPill as C, LIQUID_STRIP_STYLE as _, LIQUID_PILL_STYLE as P, liquidPillClassName as S, liquidItemClassName as j, liquidStripClassName as x } from "./liquid-pill.js";
|
|
5
|
+
import { LiquidCount as D } from "./LiquidCount.js";
|
|
6
|
+
import { shownCount as K, countName as R, LIQUID_COUNT_LABELS as U } from "./liquid-count.js";
|
|
7
|
+
import { withLabels as k } from "../../../lib/labels.js";
|
|
8
|
+
import { cn as E } from "../../../lib/utils.js";
|
|
9
|
+
function Q({
|
|
10
|
+
href: r,
|
|
11
|
+
className: e,
|
|
12
|
+
children: s,
|
|
13
|
+
onClick: u,
|
|
14
|
+
"aria-current": i,
|
|
15
|
+
"aria-label": t,
|
|
16
|
+
"data-tab-id": d
|
|
13
17
|
}) {
|
|
14
|
-
return /* @__PURE__ */
|
|
18
|
+
return /* @__PURE__ */ o("a", { href: r, className: e, onClick: u, "aria-current": i, "aria-label": t, "data-tab-id": d, children: s });
|
|
15
19
|
}
|
|
16
|
-
function
|
|
17
|
-
items:
|
|
18
|
-
activeTab:
|
|
19
|
-
onChange:
|
|
20
|
-
linkComponent:
|
|
21
|
-
variant:
|
|
20
|
+
function z({
|
|
21
|
+
items: r,
|
|
22
|
+
activeTab: e,
|
|
23
|
+
onChange: s,
|
|
24
|
+
linkComponent: u,
|
|
25
|
+
variant: i = "pill",
|
|
22
26
|
fullWidth: t = !1,
|
|
23
|
-
size:
|
|
24
|
-
"aria-label":
|
|
25
|
-
"aria-labelledby":
|
|
26
|
-
className:
|
|
27
|
+
size: d = "md",
|
|
28
|
+
"aria-label": L,
|
|
29
|
+
"aria-labelledby": y,
|
|
30
|
+
className: I,
|
|
31
|
+
labels: N
|
|
27
32
|
}) {
|
|
28
|
-
const
|
|
29
|
-
return
|
|
33
|
+
const f = k(U, N), b = m(null), p = m(null), h = u ?? Q;
|
|
34
|
+
return C(b, p, e, r.map((a) => a.id).join("\0")), /* @__PURE__ */ q(
|
|
30
35
|
"nav",
|
|
31
36
|
{
|
|
32
|
-
ref:
|
|
33
|
-
"aria-label":
|
|
34
|
-
"aria-labelledby":
|
|
37
|
+
ref: b,
|
|
38
|
+
"aria-label": L,
|
|
39
|
+
"aria-labelledby": y,
|
|
35
40
|
"data-testid": "liquid-nav",
|
|
36
|
-
style:
|
|
37
|
-
className: x(
|
|
41
|
+
style: _,
|
|
42
|
+
className: x(i, t, I),
|
|
38
43
|
children: [
|
|
39
|
-
/* @__PURE__ */
|
|
44
|
+
/* @__PURE__ */ o(
|
|
40
45
|
"div",
|
|
41
46
|
{
|
|
42
|
-
ref:
|
|
47
|
+
ref: p,
|
|
43
48
|
"aria-hidden": !0,
|
|
44
49
|
"data-testid": "liquid-tabs-pill",
|
|
45
|
-
className:
|
|
46
|
-
style:
|
|
50
|
+
className: S(i),
|
|
51
|
+
style: P
|
|
47
52
|
}
|
|
48
53
|
),
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
return /* @__PURE__ */
|
|
52
|
-
|
|
54
|
+
r.map((a) => {
|
|
55
|
+
const n = a.id === e, c = typeof a.label == "function" ? a.label(n) : a.label;
|
|
56
|
+
return /* @__PURE__ */ o(
|
|
57
|
+
h,
|
|
53
58
|
{
|
|
54
59
|
href: a.href,
|
|
55
60
|
to: a.href,
|
|
56
|
-
"aria-current":
|
|
61
|
+
"aria-current": n ? "page" : void 0,
|
|
62
|
+
"aria-label": R(c, a.count, f),
|
|
57
63
|
"data-tab-id": a.id,
|
|
58
|
-
onClick: (
|
|
59
|
-
|
|
64
|
+
onClick: (l) => {
|
|
65
|
+
l.defaultPrevented || l.button !== 0 || l.metaKey || l.ctrlKey || l.shiftKey || l.altKey || n || s?.(a.id);
|
|
60
66
|
},
|
|
61
|
-
className:
|
|
62
|
-
j({ variant:
|
|
67
|
+
className: E(
|
|
68
|
+
j({ variant: i, fullWidth: t, size: d, isActive: n }),
|
|
63
69
|
/* A button centers its label and draws no underline on its own;
|
|
64
70
|
a link does neither, and a host stylesheet may underline it. */
|
|
65
71
|
"rst:inline-flex rst:items-center rst:justify-center rst:no-underline"
|
|
66
72
|
),
|
|
67
|
-
children:
|
|
73
|
+
children: K(a.count) ? /* @__PURE__ */ o(D, { label: c, count: a.count, labels: f }) : c
|
|
68
74
|
},
|
|
69
75
|
a.id
|
|
70
76
|
);
|
|
@@ -74,5 +80,5 @@ function D({
|
|
|
74
80
|
);
|
|
75
81
|
}
|
|
76
82
|
export {
|
|
77
|
-
|
|
83
|
+
z as LiquidNav
|
|
78
84
|
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use client";"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const v=require("react/jsx-runtime"),t=require("react"),y=require("./liquid-tab-id.cjs"),p='a[href], area[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), iframe, summary, audio[controls], video[controls], [contenteditable]:not([contenteditable="false"]), [tabindex]:not([tabindex="-1"])';function x({tabsId:d,id:l,tab:n,activeTab:a,keepMounted:u=!1,tabIndex:b,children:c,...f}){const i=t.useRef(null),r=a===void 0||a===n,[h,q]=t.useState(!1);return t.useLayoutEffect(()=>{const e=i.current;if(!e)return;const o=()=>q(!!e.querySelector(p));if(o(),typeof MutationObserver>"u")return;const s=new MutationObserver(o);return s.observe(e,{childList:!0,subtree:!0,attributes:!0,attributeFilter:["disabled","tabindex","href","contenteditable"]}),()=>s.disconnect()},[]),v.jsx("div",{...f,ref:i,role:"tabpanel",id:l,"aria-labelledby":y.liquidTabId(d,n),hidden:!r,tabIndex:b??(h?void 0:0),"data-tab-panel":"",children:r||u?c:null})}exports.LiquidTabPanel=x;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { HTMLAttributes, ReactNode } from 'react';
|
|
2
|
+
export interface LiquidTabPanelProps extends Omit<HTMLAttributes<HTMLDivElement>, "id" | "role" | "hidden" | "children"> {
|
|
3
|
+
/** The `id` of the `LiquidTabs` this panel belongs to (its `id` prop). */
|
|
4
|
+
tabsId: string;
|
|
5
|
+
/** This panel's `id`: the `panelId` its tab carries, or every tab, for one panel that swaps its content. */
|
|
6
|
+
id: string;
|
|
7
|
+
/**
|
|
8
|
+
* The tab that names this panel. For one panel that swaps its content, the
|
|
9
|
+
* selected tab: the panel's name follows the selection. For a panel per tab,
|
|
10
|
+
* its own tab.
|
|
11
|
+
*/
|
|
12
|
+
tab: string;
|
|
13
|
+
/**
|
|
14
|
+
* For a panel per tab: the selected tab. The panel shows when it is `tab`,
|
|
15
|
+
* and is hidden otherwise. Leave it out for one panel that swaps its
|
|
16
|
+
* content, which always shows.
|
|
17
|
+
*/
|
|
18
|
+
activeTab?: string;
|
|
19
|
+
/**
|
|
20
|
+
* For a panel per tab: keep the content mounted while the panel is hidden,
|
|
21
|
+
* so what's in it (a half-filled form, a scroll position) survives a switch
|
|
22
|
+
* away and back. By default a hidden panel renders no content. The panel
|
|
23
|
+
* element itself always stays, hidden, so its tab's `aria-controls` points
|
|
24
|
+
* at something.
|
|
25
|
+
*/
|
|
26
|
+
keepMounted?: boolean;
|
|
27
|
+
children?: ReactNode;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The panel a `LiquidTabs` tab controls, paired with it by construction:
|
|
31
|
+
* `role="tabpanel"`, its `id` the tab's `panelId`, and `aria-labelledby`
|
|
32
|
+
* pointing at the tab through `liquidTabId`, so it's named for its tab.
|
|
33
|
+
*
|
|
34
|
+
* ```tsx
|
|
35
|
+
* <LiquidTabs id="views" tabs={[{ id: "open", label: "Open", panelId: "views-panel" }, …]} … />
|
|
36
|
+
* <LiquidTabPanel tabsId="views" id="views-panel" tab={view}>…</LiquidTabPanel>
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* Two ways to lay it out. **One panel that swaps its content:** every tab
|
|
40
|
+
* carries the same `panelId`, and `tab` is the selected tab. **A panel per
|
|
41
|
+
* tab:** each tab has its own `panelId`, each panel its own `tab`, and every
|
|
42
|
+
* panel gets `activeTab`, so only the selected one shows.
|
|
43
|
+
*
|
|
44
|
+
* The WAI tabs pattern puts the panel in the tab order when nothing inside it
|
|
45
|
+
* can take focus, so a keyboard user can reach and scroll what was just
|
|
46
|
+
* selected. Whether the content holds anything focusable isn't known until it
|
|
47
|
+
* renders, and it can change as content loads, so the panel starts reachable
|
|
48
|
+
* (`tabIndex={0}`) and leaves the tab order once its content holds something
|
|
49
|
+
* that can be Tabbed to, rechecking as the content changes. Pass `tabIndex`
|
|
50
|
+
* to decide it yourself.
|
|
51
|
+
*/
|
|
52
|
+
export declare function LiquidTabPanel({ tabsId, id, tab, activeTab, keepMounted, tabIndex, children, ...rest }: LiquidTabPanelProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as p } from "react/jsx-runtime";
|
|
3
|
+
import { useRef as m, useState as v, useLayoutEffect as x } from "react";
|
|
4
|
+
import { liquidTabId as y } from "./liquid-tab-id.js";
|
|
5
|
+
const L = 'a[href], area[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), iframe, summary, audio[controls], video[controls], [contenteditable]:not([contenteditable="false"]), [tabindex]:not([tabindex="-1"])';
|
|
6
|
+
function w({ tabsId: d, id: s, tab: t, activeTab: n, keepMounted: l = !1, tabIndex: b, children: u, ...c }) {
|
|
7
|
+
const r = m(null), a = n === void 0 || n === t, [f, h] = v(!1);
|
|
8
|
+
return x(() => {
|
|
9
|
+
const e = r.current;
|
|
10
|
+
if (!e) return;
|
|
11
|
+
const o = () => h(!!e.querySelector(L));
|
|
12
|
+
if (o(), typeof MutationObserver > "u") return;
|
|
13
|
+
const i = new MutationObserver(o);
|
|
14
|
+
return i.observe(e, { childList: !0, subtree: !0, attributes: !0, attributeFilter: ["disabled", "tabindex", "href", "contenteditable"] }), () => i.disconnect();
|
|
15
|
+
}, []), /* @__PURE__ */ p(
|
|
16
|
+
"div",
|
|
17
|
+
{
|
|
18
|
+
...c,
|
|
19
|
+
ref: r,
|
|
20
|
+
role: "tabpanel",
|
|
21
|
+
id: s,
|
|
22
|
+
"aria-labelledby": y(d, t),
|
|
23
|
+
hidden: !a,
|
|
24
|
+
tabIndex: b ?? (f ? void 0 : 0),
|
|
25
|
+
"data-tab-panel": "",
|
|
26
|
+
children: a || l ? u : null
|
|
27
|
+
}
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
export {
|
|
31
|
+
w as LiquidTabPanel
|
|
32
|
+
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
"use client";"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const
|
|
1
|
+
"use client";"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const u=require("react/jsx-runtime"),d=require("react"),o=require("./liquid-pill.cjs"),U=require("./liquid-tab-id.cjs"),O=require("./LiquidCount.cjs"),m=require("./liquid-count.cjs"),Q=require("../../../lib/labels.cjs"),B=require("../../../internal/key-focus.cjs");function K({tabs:t,activeTab:r,onChange:C,variant:c="pill",fullWidth:y=!1,size:A="md",activation:R="automatic",id:h,"aria-label":w,"aria-labelledby":N,className:j,labels:k}){B.useKeyFocus();const L=Q.withLabels(m.LIQUID_COUNT_LABELS,k),a=d.useRef(null),I=d.useRef(null),x=d.useId(),p=h??x;o.useLiquidPill(a,I,r,t.map(e=>e.id).join("\0"));const b=e=>{e!==r&&C(e)},[f,S]=d.useState(null),T=t.some(e=>e.id===r),P=f!==null&&t.some(e=>e.id===f)?f:T?r:t[0]?.id,_=e=>{e.currentTarget.contains(e.relatedTarget)||S(null)},D=e=>{const i=a.current;if(!i)return;const l=Array.from(i.querySelectorAll('[role="tab"]')),n=l.indexOf(e.target);if(n===-1)return;const q=l.length-1,g=getComputedStyle(i).direction==="rtl",E=g?"ArrowLeft":"ArrowRight",F=g?"ArrowRight":"ArrowLeft";let s;if(e.key===E)s=n===q?0:n+1;else if(e.key===F)s=n===0?q:n-1;else if(e.key==="Home")s=0;else if(e.key==="End")s=q;else return;e.preventDefault(),l[s].focus(),R==="automatic"&&b(t[s].id)};return u.jsxs("div",{ref:a,id:p,role:"tablist","aria-label":w,"aria-labelledby":N,"data-testid":"liquid-tabs",style:o.LIQUID_STRIP_STYLE,className:o.liquidStripClassName(c,y,j),onKeyDown:D,onBlur:_,children:[u.jsx("div",{ref:I,"aria-hidden":!0,"data-testid":"liquid-tabs-pill",className:o.liquidPillClassName(c),style:o.LIQUID_PILL_STYLE}),t.map(e=>{const i=e.id===r,l=typeof e.label=="function"?e.label(i):e.label;return u.jsx("button",{type:"button",role:"tab",id:U.liquidTabId(p,e.id),"aria-selected":i,"aria-controls":e.panelId,"aria-label":m.countName(l,e.count,L),tabIndex:e.id===P?0:-1,"data-tab-id":e.id,"data-testid":`liquid-tab-${e.id}`,onClick:()=>b(e.id),onFocus:()=>S(e.id),className:o.liquidItemClassName({variant:c,fullWidth:y,size:A,isActive:i}),children:m.shownCount(e.count)?u.jsx(O.LiquidCount,{label:l,count:e.count,labels:L}):l},e.id)})]})}exports.LiquidTabs=K;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { ReactNode } from 'react';
|
|
2
2
|
import { LiquidSize, LiquidVariant } from './liquid-pill';
|
|
3
|
+
import { LiquidTabsLabels } from './liquid-count';
|
|
4
|
+
export type { LiquidTabsLabels };
|
|
3
5
|
export interface TabItem {
|
|
4
6
|
id: string;
|
|
5
7
|
/** Static label, or a render function that receives whether this tab is active. */
|
|
@@ -9,6 +11,18 @@ export interface TabItem {
|
|
|
9
11
|
* every tab the same id when one panel swaps its content.
|
|
10
12
|
*/
|
|
11
13
|
panelId?: string;
|
|
14
|
+
/**
|
|
15
|
+
* A number in a small mark beside the label, read as part of the tab's
|
|
16
|
+
* name: "Open, 12 items" (`labels.count` says it).
|
|
17
|
+
*
|
|
18
|
+
* **Zero shows**, as "0" and "0 items": a count that vanished at zero would
|
|
19
|
+
* change the tab's width and move every tab after it, and an empty list is
|
|
20
|
+
* worth saying. To hide it, pass `undefined` for zero. `undefined` means no
|
|
21
|
+
* count and no mark. A negative or fractional count isn't shown at all,
|
|
22
|
+
* never rounded or clamped. Large counts aren't abbreviated: 12,345 shows
|
|
23
|
+
* as "12,345" unless `labels.countMark` writes it shorter.
|
|
24
|
+
*/
|
|
25
|
+
count?: number;
|
|
12
26
|
}
|
|
13
27
|
export interface LiquidTabsProps {
|
|
14
28
|
tabs: TabItem[];
|
|
@@ -49,6 +63,8 @@ export interface LiquidTabsProps {
|
|
|
49
63
|
"aria-labelledby"?: string;
|
|
50
64
|
/** Extra classes applied to the outer container. */
|
|
51
65
|
className?: string;
|
|
66
|
+
/** Every word a count says, with English defaults: see `TabItem.count`. */
|
|
67
|
+
labels?: Partial<LiquidTabsLabels>;
|
|
52
68
|
}
|
|
53
69
|
/**
|
|
54
70
|
* A controlled tab strip with a liquid sliding indicator.
|
|
@@ -64,6 +80,9 @@ export interface LiquidTabsProps {
|
|
|
64
80
|
* The pill follows `activeTab`, never the click, so a change the consumer
|
|
65
81
|
* vetoes, or one that waits on navigation, never leaves it on the wrong tab.
|
|
66
82
|
*
|
|
83
|
+
* A tab can carry a `count`, and a panel pairs with its tab by construction
|
|
84
|
+
* through `LiquidTabPanel`.
|
|
85
|
+
*
|
|
67
86
|
* For navigation between routes, use `LiquidNav`, which renders links.
|
|
68
87
|
*/
|
|
69
|
-
export declare function LiquidTabs({ tabs, activeTab, onChange, variant, fullWidth, size, activation, id, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, className, }: LiquidTabsProps): import("react/jsx-runtime").JSX.Element;
|
|
88
|
+
export declare function LiquidTabs({ tabs, activeTab, onChange, variant, fullWidth, size, activation, id, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, className, labels, }: LiquidTabsProps): import("react/jsx-runtime").JSX.Element;
|