@toyonix/web-kit 0.1.1 → 0.1.3

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.
@@ -111,6 +111,83 @@
111
111
  outline: var(--tx-border-width) solid var(--tx-border);
112
112
  }
113
113
 
114
+ /*
115
+ * Narrow screens: stay on one row. The header gets more compact, and if the app links still
116
+ * don't fit, they scroll sideways (AppShell scrolls the current app into view).
117
+ */
118
+ @media (max-width: 640px) {
119
+ .tx-header__inner {
120
+ gap: 10px;
121
+ min-height: 56px;
122
+ }
123
+
124
+ .tx-logo {
125
+ flex: none;
126
+ gap: 8px;
127
+ font-size: 20px;
128
+ }
129
+
130
+ .tx-logo__mark {
131
+ width: 26px;
132
+ height: 26px;
133
+ font-size: 13px;
134
+ }
135
+
136
+ .tx-nav {
137
+ flex: 0 1 auto;
138
+ flex-wrap: nowrap;
139
+ min-width: 0;
140
+ padding: 3px; /* room for the current app's outline */
141
+ overflow-x: auto;
142
+ scrollbar-width: none;
143
+ }
144
+
145
+ .tx-nav::-webkit-scrollbar {
146
+ display: none;
147
+ }
148
+
149
+ /* Fade only the edges that have more links beyond them (set by AppShell), hinting at the scroll. */
150
+ .tx-nav[data-more-left],
151
+ .tx-nav[data-more-right] {
152
+ --tx-nav-fade-left: 0px;
153
+ --tx-nav-fade-right: 0px;
154
+ -webkit-mask-image: linear-gradient(
155
+ to right,
156
+ transparent,
157
+ #000 var(--tx-nav-fade-left),
158
+ #000 calc(100% - var(--tx-nav-fade-right)),
159
+ transparent
160
+ );
161
+ mask-image: linear-gradient(
162
+ to right,
163
+ transparent,
164
+ #000 var(--tx-nav-fade-left),
165
+ #000 calc(100% - var(--tx-nav-fade-right)),
166
+ transparent
167
+ );
168
+ }
169
+
170
+ .tx-nav[data-more-left] {
171
+ --tx-nav-fade-left: 22px;
172
+ }
173
+
174
+ .tx-nav[data-more-right] {
175
+ --tx-nav-fade-right: 22px;
176
+ }
177
+
178
+ .tx-nav a {
179
+ flex: none;
180
+ padding: 6px 9px;
181
+ font-size: 14px;
182
+ }
183
+
184
+ .tx-header__inner .tx-icon-btn {
185
+ flex: none;
186
+ width: 36px;
187
+ height: 36px;
188
+ }
189
+ }
190
+
114
191
  .tx-subnav {
115
192
  border-bottom: var(--tx-border-width) solid var(--tx-border);
116
193
  background: var(--tx-surface);
@@ -891,9 +968,204 @@
891
968
  outline: var(--tx-border-width) solid var(--tx-border);
892
969
  }
893
970
 
971
+ /* ============================== table of contents ============================== */
972
+
973
+ /*
974
+ * Wide screens: article + sticky "On this page" column. Narrow screens: the column is hidden
975
+ * and a floating button opens the same list in a menu that slides in from the right.
976
+ */
977
+ .tx-with-toc {
978
+ display: grid;
979
+ grid-template-columns: minmax(0, 1fr);
980
+ gap: 64px;
981
+ align-items: start;
982
+ }
983
+
984
+ .tx-toc {
985
+ display: none;
986
+ }
987
+
988
+ @media (min-width: 1100px) {
989
+ .tx-with-toc {
990
+ grid-template-columns: minmax(0, 1fr) 240px;
991
+ }
992
+
993
+ .tx-toc {
994
+ display: block;
995
+ position: sticky;
996
+ top: 96px;
997
+ max-height: calc(100vh - 120px);
998
+ overflow-y: auto;
999
+ padding: 6px 4px 12px 0;
1000
+ }
1001
+
1002
+ .tx-toc-trigger {
1003
+ display: none !important;
1004
+ }
1005
+ }
1006
+
1007
+ .tx-toc__title {
1008
+ margin-bottom: 18px;
1009
+ font-size: 18px;
1010
+ }
1011
+
1012
+ .tx-toc__list {
1013
+ margin: 0;
1014
+ padding: 0 0 0 14px;
1015
+ border-left: var(--tx-border-width) solid var(--tx-border);
1016
+ list-style: none;
1017
+ }
1018
+
1019
+ .tx-toc__item {
1020
+ margin: 0;
1021
+ }
1022
+
1023
+ .tx-toc__item--sub {
1024
+ padding-left: 14px;
1025
+ }
1026
+
1027
+ .tx-toc__link {
1028
+ display: block;
1029
+ padding: 5px 0;
1030
+ color: var(--tx-text-muted);
1031
+ font-size: 14px;
1032
+ line-height: 1.35;
1033
+ text-decoration: none;
1034
+ }
1035
+
1036
+ .tx-toc__item--sub .tx-toc__link {
1037
+ font-size: 13px;
1038
+ }
1039
+
1040
+ .tx-toc__link:hover {
1041
+ color: var(--tx-text);
1042
+ }
1043
+
1044
+ .tx-toc__link[aria-current] {
1045
+ color: var(--tx-text);
1046
+ font-weight: 600;
1047
+ }
1048
+
1049
+ /* The current section gets the marker highlight, like <em> in headlines. */
1050
+ .tx-toc__link[aria-current] .tx-toc__text {
1051
+ background: linear-gradient(transparent 58%, var(--tx-accent) 58%, var(--tx-accent) 92%, transparent 92%);
1052
+ -webkit-box-decoration-break: clone;
1053
+ box-decoration-break: clone;
1054
+ }
1055
+
1056
+ .tx-toc-trigger {
1057
+ position: fixed;
1058
+ right: 16px;
1059
+ bottom: calc(16px + env(safe-area-inset-bottom));
1060
+ z-index: 40;
1061
+ font-size: 14px;
1062
+ }
1063
+
1064
+ .tx-toc-overlay {
1065
+ z-index: 60;
1066
+ }
1067
+
1068
+ .tx-toc-drawer {
1069
+ position: fixed;
1070
+ top: 0;
1071
+ right: 0;
1072
+ bottom: 0;
1073
+ z-index: 61;
1074
+ display: flex;
1075
+ flex-direction: column;
1076
+ width: min(340px, 86vw);
1077
+ padding: 20px 20px calc(24px + env(safe-area-inset-bottom));
1078
+ overflow-y: auto;
1079
+ border-left: var(--tx-border-width) solid var(--tx-border);
1080
+ background: var(--tx-surface);
1081
+ box-shadow: -6px 0 0 var(--tx-border);
1082
+ color: var(--tx-text);
1083
+ }
1084
+
1085
+ .tx-toc-drawer[data-state="open"] {
1086
+ animation: tx-slide-in-right 0.22s ease-out;
1087
+ }
1088
+
1089
+ .tx-toc-drawer[data-state="closed"] {
1090
+ animation: tx-slide-out-right 0.16s ease-in;
1091
+ }
1092
+
1093
+ .tx-toc-overlay[data-state="open"] {
1094
+ animation: tx-fade-in 0.2s ease-out;
1095
+ }
1096
+
1097
+ .tx-toc-overlay[data-state="closed"] {
1098
+ animation: tx-fade-out 0.16s ease-in;
1099
+ }
1100
+
1101
+ .tx-toc-drawer__head {
1102
+ display: flex;
1103
+ align-items: flex-start;
1104
+ justify-content: space-between;
1105
+ gap: 12px;
1106
+ margin-bottom: 4px;
1107
+ }
1108
+
1109
+ .tx-toc-drawer__close {
1110
+ padding: 6px 12px;
1111
+ }
1112
+
1113
+ .tx-toc-drawer .tx-toc__link {
1114
+ padding: 10px 0; /* bigger tap targets */
1115
+ font-size: 16px;
1116
+ }
1117
+
1118
+ .tx-toc-drawer .tx-toc__item--sub .tx-toc__link {
1119
+ font-size: 15px;
1120
+ }
1121
+
1122
+ @keyframes tx-slide-in-right {
1123
+ from {
1124
+ transform: translateX(100%);
1125
+ }
1126
+ }
1127
+
1128
+ @keyframes tx-slide-out-right {
1129
+ to {
1130
+ transform: translateX(100%);
1131
+ }
1132
+ }
1133
+
1134
+ @keyframes tx-fade-in {
1135
+ from {
1136
+ opacity: 0;
1137
+ }
1138
+ }
1139
+
1140
+ @keyframes tx-fade-out {
1141
+ to {
1142
+ opacity: 0;
1143
+ }
1144
+ }
1145
+
1146
+ /* Anchor jumps (table of contents, #links) land below the sticky header, not under it. */
1147
+ .tx-prose :is(h2, h3, h4) {
1148
+ scroll-margin-top: 96px;
1149
+ }
1150
+
1151
+ .tx-visually-hidden {
1152
+ position: absolute;
1153
+ width: 1px;
1154
+ height: 1px;
1155
+ overflow: hidden;
1156
+ clip: rect(0 0 0 0);
1157
+ clip-path: inset(50%);
1158
+ white-space: nowrap;
1159
+ }
1160
+
894
1161
  /* ============================== motion ============================== */
895
1162
 
896
1163
  @media (prefers-reduced-motion: reduce) {
1164
+ .tx-toc-drawer,
1165
+ .tx-toc-overlay {
1166
+ animation: none !important;
1167
+ }
1168
+
897
1169
  .tx-btn,
898
1170
  .tx-card--link,
899
1171
  .tx-switch,
@@ -3,6 +3,7 @@ import { appHref, getAppId } from "../env.js";
3
3
  import { navApps } from "../registry/index.js";
4
4
  import { ThemeToggle } from "./ThemeToggle.js";
5
5
  import { useRegistry } from "./use-registry.js";
6
+ import { useEffect, useRef } from "react";
6
7
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
7
8
  //#region src/react/AppShell.tsx
8
9
  /**
@@ -12,6 +13,29 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
12
13
  function AppShell({ children, registry, appTitle, nav, footer }) {
13
14
  const appId = getAppId();
14
15
  const apps = navApps(useRegistry(registry));
16
+ const navRef = useRef(null);
17
+ const appCount = apps.length;
18
+ useEffect(() => {
19
+ const nav = navRef.current;
20
+ if (!nav) return;
21
+ const markEdges = () => {
22
+ const max = nav.scrollWidth - nav.clientWidth;
23
+ nav.toggleAttribute("data-more-left", max > 1 && nav.scrollLeft > 1);
24
+ nav.toggleAttribute("data-more-right", max > 1 && nav.scrollLeft < max - 1);
25
+ };
26
+ const update = () => {
27
+ const current = nav.querySelector("[aria-current]");
28
+ if (current && nav.scrollWidth > nav.clientWidth + 1) nav.scrollLeft = current.offsetLeft - (nav.clientWidth - current.offsetWidth) / 2;
29
+ markEdges();
30
+ };
31
+ update();
32
+ nav.addEventListener("scroll", markEdges, { passive: true });
33
+ window.addEventListener("resize", update);
34
+ return () => {
35
+ nav.removeEventListener("scroll", markEdges);
36
+ window.removeEventListener("resize", update);
37
+ };
38
+ }, [appCount]);
15
39
  if (appId !== "root" && !apps.some((app) => app.id === appId)) apps.push({
16
40
  id: appId,
17
41
  title: appTitle ?? appId,
@@ -42,6 +66,7 @@ function AppShell({ children, registry, appTitle, nav, footer }) {
42
66
  }), /* @__PURE__ */ jsx("span", { children: "toyonix" })]
43
67
  }),
44
68
  /* @__PURE__ */ jsxs("nav", {
69
+ ref: navRef,
45
70
  className: "tx-nav",
46
71
  "aria-label": "toyonix apps",
47
72
  children: [/* @__PURE__ */ jsx("a", {
@@ -0,0 +1,23 @@
1
+ //#region src/react/TableOfContents.d.ts
2
+ export interface TocItem {
3
+ /** The heading element's id (the anchor). */
4
+ id: string;
5
+ text: string;
6
+ /** 2 for `##`, 3 for `###`. Deeper levels are better left out. */
7
+ depth: 2 | 3;
8
+ }
9
+ export interface TableOfContentsProps {
10
+ items: TocItem[];
11
+ /** Below this many items nothing renders (a short note doesn't need an index). Default 3. */
12
+ minItems?: number;
13
+ title?: string;
14
+ className?: string;
15
+ }
16
+ /**
17
+ * "On this page" navigation for long articles. Place it as the second child of a `.tx-with-toc`
18
+ * grid, next to the article. Wide screens get a sticky column; narrow screens get a floating
19
+ * button that opens a menu sliding in from the right. Works without JS (plain anchor links);
20
+ * JS adds the current-section highlight and the mobile menu.
21
+ */
22
+ export declare function TableOfContents({ items, minItems, title, className }: TableOfContentsProps): import("react").JSX.Element | null;
23
+ //#endregion
@@ -0,0 +1,135 @@
1
+ "use client";
2
+ import { cx } from "./cx.js";
3
+ import { useCallback, useEffect, useMemo, useState } from "react";
4
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
5
+ import { Dialog } from "radix-ui";
6
+ //#region src/react/TableOfContents.tsx
7
+ /** How far below the viewport top a heading counts as "current" (clears the sticky header). */
8
+ const ACTIVE_OFFSET = 120;
9
+ /** The id of the last heading scrolled past, or the first one before any. */
10
+ function useActiveHeading(ids) {
11
+ const [active, setActive] = useState(ids[0]);
12
+ useEffect(() => {
13
+ if (ids.length === 0) return;
14
+ let frame = 0;
15
+ const update = () => {
16
+ frame = 0;
17
+ let current = ids[0];
18
+ for (const id of ids) {
19
+ const element = document.getElementById(id);
20
+ if (element && element.getBoundingClientRect().top <= ACTIVE_OFFSET) current = id;
21
+ }
22
+ setActive(current);
23
+ };
24
+ const onScroll = () => {
25
+ if (!frame) frame = requestAnimationFrame(update);
26
+ };
27
+ update();
28
+ window.addEventListener("scroll", onScroll, { passive: true });
29
+ window.addEventListener("resize", onScroll);
30
+ return () => {
31
+ window.removeEventListener("scroll", onScroll);
32
+ window.removeEventListener("resize", onScroll);
33
+ if (frame) cancelAnimationFrame(frame);
34
+ };
35
+ }, [ids]);
36
+ return active;
37
+ }
38
+ function TocList({ items, active, onNavigate }) {
39
+ return /* @__PURE__ */ jsx("ol", {
40
+ className: "tx-toc__list",
41
+ children: items.map((item) => /* @__PURE__ */ jsx("li", {
42
+ className: cx("tx-toc__item", item.depth === 3 && "tx-toc__item--sub"),
43
+ children: /* @__PURE__ */ jsx("a", {
44
+ className: "tx-toc__link",
45
+ href: `#${item.id}`,
46
+ "aria-current": item.id === active ? "location" : void 0,
47
+ onClick: onNavigate ? (event) => onNavigate(event, item.id) : void 0,
48
+ children: /* @__PURE__ */ jsx("span", {
49
+ className: "tx-toc__text",
50
+ children: item.text
51
+ })
52
+ })
53
+ }, item.id))
54
+ });
55
+ }
56
+ /**
57
+ * "On this page" navigation for long articles. Place it as the second child of a `.tx-with-toc`
58
+ * grid, next to the article. Wide screens get a sticky column; narrow screens get a floating
59
+ * button that opens a menu sliding in from the right. Works without JS (plain anchor links);
60
+ * JS adds the current-section highlight and the mobile menu.
61
+ */
62
+ function TableOfContents({ items, minItems = 3, title = "On this page", className }) {
63
+ const [open, setOpen] = useState(false);
64
+ const [pending, setPending] = useState(null);
65
+ const idKey = items.map((item) => item.id).join("\n");
66
+ const active = useActiveHeading(useMemo(() => idKey ? idKey.split("\n") : [], [idKey]));
67
+ const navigateFromMenu = useCallback((event, id) => {
68
+ event.preventDefault();
69
+ setPending(id);
70
+ setOpen(false);
71
+ }, []);
72
+ const finishNavigation = useCallback((event) => {
73
+ if (!pending) return;
74
+ event.preventDefault();
75
+ const target = document.getElementById(pending);
76
+ setPending(null);
77
+ if (!target) return;
78
+ history.pushState(null, "", `#${pending}`);
79
+ target.scrollIntoView({ block: "start" });
80
+ }, [pending]);
81
+ if (items.length < minItems) return null;
82
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("nav", {
83
+ className: cx("tx-toc", className),
84
+ "aria-label": title,
85
+ children: [/* @__PURE__ */ jsx("p", {
86
+ className: "tx-section-label tx-toc__title",
87
+ children: title
88
+ }), /* @__PURE__ */ jsx(TocList, {
89
+ items,
90
+ active
91
+ })]
92
+ }), /* @__PURE__ */ jsxs(Dialog.Root, {
93
+ open,
94
+ onOpenChange: setOpen,
95
+ children: [/* @__PURE__ */ jsxs(Dialog.Trigger, {
96
+ className: "tx-btn tx-btn--primary tx-toc-trigger",
97
+ "aria-label": `Open: ${title}`,
98
+ children: [
99
+ /* @__PURE__ */ jsx("span", {
100
+ "aria-hidden": "true",
101
+ children: "☰"
102
+ }),
103
+ " ",
104
+ title
105
+ ]
106
+ }), /* @__PURE__ */ jsxs(Dialog.Portal, { children: [/* @__PURE__ */ jsx(Dialog.Overlay, { className: "tx-overlay tx-toc-overlay" }), /* @__PURE__ */ jsxs(Dialog.Content, {
107
+ className: "tx-toc-drawer",
108
+ onCloseAutoFocus: finishNavigation,
109
+ children: [
110
+ /* @__PURE__ */ jsxs("div", {
111
+ className: "tx-toc-drawer__head",
112
+ children: [/* @__PURE__ */ jsx(Dialog.Title, {
113
+ className: "tx-section-label tx-toc__title",
114
+ children: title
115
+ }), /* @__PURE__ */ jsx(Dialog.Close, {
116
+ className: "tx-btn tx-btn--ghost tx-toc-drawer__close",
117
+ "aria-label": "Close",
118
+ children: "✕"
119
+ })]
120
+ }),
121
+ /* @__PURE__ */ jsx(Dialog.Description, {
122
+ className: "tx-visually-hidden",
123
+ children: "Jump to a section of this page."
124
+ }),
125
+ /* @__PURE__ */ jsx(TocList, {
126
+ items,
127
+ active,
128
+ onNavigate: navigateFromMenu
129
+ })
130
+ ]
131
+ })] })]
132
+ })] });
133
+ }
134
+ //#endregion
135
+ export { TableOfContents };
@@ -18,4 +18,5 @@ import { Dialog, DialogClose, DialogContent, DialogContentProps, DialogTrigger }
18
18
  import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./DropdownMenu.js";
19
19
  import { Tooltip, TooltipProps } from "./Tooltip.js";
20
20
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "./Tabs.js";
21
- export { Alert, type AlertTone, AppLink, type AppLinkProps, AppShell, type AppShellProps, Badge, type BadgeTone, Badges, Button, type ButtonProps, ButtonRow, type ButtonVariant, Card, CardBody, CardCta, CardGrid, CardMeta, type CardProps, CardTitle, CardTop, Checkbox, CodeBlock, Container, Dialog, DialogClose, DialogContent, type DialogContentProps, DialogTrigger, Display, DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, Eyebrow, Field, Form, Input, Lead, LinkButton, type LinkButtonProps, LinkCard, Mark, Prose, Radio, Row, Section, SectionLabel, Select, Stack, Switch, Table, Tabs, TabsContent, TabsList, TabsTrigger, Tag, Tags, Textarea, ThemeScript, type ThemeState, ThemeToggle, Tooltip, type TooltipProps, cx, useRegistry, useTheme };
21
+ import { TableOfContents, TableOfContentsProps, TocItem } from "./TableOfContents.js";
22
+ export { Alert, type AlertTone, AppLink, type AppLinkProps, AppShell, type AppShellProps, Badge, type BadgeTone, Badges, Button, type ButtonProps, ButtonRow, type ButtonVariant, Card, CardBody, CardCta, CardGrid, CardMeta, type CardProps, CardTitle, CardTop, Checkbox, CodeBlock, Container, Dialog, DialogClose, DialogContent, type DialogContentProps, DialogTrigger, Display, DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, Eyebrow, Field, Form, Input, Lead, LinkButton, type LinkButtonProps, LinkCard, Mark, Prose, Radio, Row, Section, SectionLabel, Select, Stack, Switch, Table, TableOfContents, type TableOfContentsProps, Tabs, TabsContent, TabsList, TabsTrigger, Tag, Tags, Textarea, ThemeScript, type ThemeState, ThemeToggle, type TocItem, Tooltip, type TooltipProps, cx, useRegistry, useTheme };
@@ -18,4 +18,5 @@ import { Dialog, DialogClose, DialogContent, DialogTrigger } from "./Dialog.js";
18
18
  import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./DropdownMenu.js";
19
19
  import { Tooltip } from "./Tooltip.js";
20
20
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "./Tabs.js";
21
- export { Alert, AppLink, AppShell, Badge, Badges, Button, ButtonRow, Card, CardBody, CardCta, CardGrid, CardMeta, CardTitle, CardTop, Checkbox, CodeBlock, Container, Dialog, DialogClose, DialogContent, DialogTrigger, Display, DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, Eyebrow, Field, Form, Input, Lead, LinkButton, LinkCard, Mark, Prose, Radio, Row, Section, SectionLabel, Select, Stack, Switch, Table, Tabs, TabsContent, TabsList, TabsTrigger, Tag, Tags, Textarea, ThemeScript, ThemeToggle, Tooltip, cx, useRegistry, useTheme };
21
+ import { TableOfContents } from "./TableOfContents.js";
22
+ export { Alert, AppLink, AppShell, Badge, Badges, Button, ButtonRow, Card, CardBody, CardCta, CardGrid, CardMeta, CardTitle, CardTop, Checkbox, CodeBlock, Container, Dialog, DialogClose, DialogContent, DialogTrigger, Display, DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, Eyebrow, Field, Form, Input, Lead, LinkButton, LinkCard, Mark, Prose, Radio, Row, Section, SectionLabel, Select, Stack, Switch, Table, TableOfContents, Tabs, TabsContent, TabsList, TabsTrigger, Tag, Tags, Textarea, ThemeScript, ThemeToggle, Tooltip, cx, useRegistry, useTheme };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toyonix/web-kit",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Shared design system, components, and tooling for every toyonix static site.",
5
5
  "license": "MIT",
6
6
  "author": "ToYoNiX",