highsoft-ui 1.0.172 → 1.0.173

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/CLAUDE.md CHANGED
@@ -960,6 +960,42 @@ import NextLink from 'next/link'
960
960
  - The disabled edge card is a non-interactive `aria-disabled` span — not focusable, so keyboard users skip straight to the real links, while AT can still read "Previous —" as "there is no previous page".
961
961
  - DOM order is prev → back → next, matching the desktop visual order, so focus moves left-to-right across the row.
962
962
 
963
+ ### TableOfContents
964
+ "On this page" navigation for an article: a rule down the left, a semibold title and one link per heading. Uncontrolled by default with a built-in scroll spy; pass `activeId` to drive the highlight yourself.
965
+
966
+ ```tsx
967
+ const items: TableOfContentsItem[] = [
968
+ { id: 'requirements', title: 'Requirements' },
969
+ { id: 'install', title: '1. Install Highcharts React' },
970
+ { id: 'customize', title: '3. Customize your chart' },
971
+ { id: 'chart-elements', title: 'Chart elements', level: 2 }, // indented under the row above
972
+ ]
973
+
974
+ // Scroll spy on: the current row is the last heading past the fold line.
975
+ // Set spyOffset to the sticky header's height (plus the headings' scroll-margin-top).
976
+ <TableOfContents items={items} spyOffset={96} style={{ position: 'sticky', top: 96 }} />
977
+
978
+ // Controlled: you own the highlight; onActiveChange reports clicks
979
+ <TableOfContents items={items} activeId={active} onActiveChange={setActive} />
980
+
981
+ // Take over the navigation (eased scroll with a header offset) — the row still goes current
982
+ <TableOfContents items={items} onItemClick={(item, e) => { e.preventDefault(); scrollTo(item.id) }} />
983
+ ```
984
+
985
+ - **`items`:** `TableOfContentsItem[]` — `{ id, title, level?, href? }`. `id` is the heading element's id: the link target (`#id`) and what the spy watches. `level` `1` (default) | `2` | `3` indents 12px per step. `href` overrides the `#id` target. Renders nothing when empty.
986
+ - **`title`:** the heading above the list (default `'On this page'`); `null` hides it.
987
+ - **`activeId` / `onActiveChange(id)`:** controlled highlight; `null` highlights nothing. When `activeId` is given the spy is off. `onActiveChange` fires on spy changes and on clicks. **`defaultActiveId`** seeds the highlight in uncontrolled mode; the spy takes over once it finds headings, and while there are none (not rendered yet, or `spy={false}`) the highlight stays put and clicks move it.
988
+ - **`spy`** (default `true`) / **`spyOffset`** (default `0`): the current heading is the last one whose top has crossed `spyOffset` px from the top of the viewport. The listener runs in the capture phase, so a scrolling container works as well as the window. **A clicked row stays current while its heading is on screen** even if it can't reach the fold line (a short last section never can); any wheel, touch or key input hands control back to the spy. A hash in the URL on mount pins its row the same way.
989
+ - **`onItemClick(item, event)`:** runs before the hash navigation; `preventDefault()` to take it over.
990
+ - Rows are 14px/1.3 tertiary text on a 25px pitch (7px gap), text 15px in from the rule, 32px above the title and 12px below the last row — the Figma geometry. Hover lifts a row to `--HS-text-primary-default` and grows a 2px `--HS-surface-ui-border-300` bar over the rule; the current row keeps the bar in `--HS-surface-ui-border-1000`. The bar is a `::before` whose width animates in and out on `--hsui-toc-bar-bounce` (default `--animations-bounce-fast`, so it overshoots a touch and settles); it isn't a border, so the text never moves. **Colours are not transitioned** — neither the text nor the bar — for the same reason as ButtonGroup: a colour transition can't tell a hover from a `data-theme` flip, so it would fade the rail behind the rest of the page on a theme change. The rule is a pseudo-element too, so the bar can cover it. Everything is `--HS-*` tokens, so dark mode is automatic (border-1000 remaps to neutral-0). Width, stickiness and the breakpoint that hides the rail belong to the page.
991
+
992
+ **Accessibility:**
993
+ - A real `<nav>` named by its visible title via `aria-labelledby` (AT announces "On this page, navigation"); with `title={null}` it takes `aria-label`, falling back to "On this page". Pass `aria-label` explicitly to override either way.
994
+ - The rows are a `<ul role="list">` of real links (the role keeps Safari/VoiceOver from dropping list semantics from an unstyled list), so a screen reader hears "list, 6 items" and can jump between them. The current row carries `aria-current="location"` — the value for "where you are within the page" — so it is announced whichever way it was reached.
995
+ - Keyboard: Tab through the links, Enter follows the hash and marks the row current; `:focus-visible` draws the shared two-tone brand focus ring (the `a11y-focus-ring` mixin, animating on `--animations-bounce-default`). Keyboard scrolling counts as reader input and releases a pinned row to the spy.
996
+ - Hover and current states pair the bar with a text-colour change, so neither rests on colour alone. The bar's width grows on the bounce token and is instant under `prefers-reduced-motion: reduce`, which leaves nothing animating; colours switch instantly in every mode. The spy only swaps which row is current.
997
+ - Rows are 18px tall on a 25px pitch: under the 24px minimum of WCAG 2.5.8, but the pitch keeps every target at least 24px from the next, which is the criterion's spacing exception. Nested rows indent without shrinking.
998
+
963
999
  ## Hooks
964
1000
 
965
1001
  ### useSuccessFlash
package/README.md CHANGED
@@ -15,6 +15,7 @@ The Highsoft design system — a React component library, design tokens, a Story
15
15
  | --- | --- | --- | --- |
16
16
  | `lib/` | The component library (the published package) | `npm run build` → `dist/` | npm — `highsoft-ui` |
17
17
  | `src/` | The Astro docs site (multi-page, React islands) | `npm run build:app` → `dist-app/` | design.highdev.dev |
18
+ | `tools/icon-studio/` | 3D icon studio and local Blender helper | `npm run build:app` | Design site only — never npm |
18
19
  | `stories/` | Storybook stories | `npm run build-storybook` | storybook.highdev.dev |
19
20
 
20
21
  `lib/` is the source of truth; the docs site and Storybook both consume it directly (via the `highsoft-ui` alias), so a change to a component shows up in all three.
@@ -0,0 +1,93 @@
1
+ "use client";
2
+ import { t as e } from "./clsx-BWCAbrjV.js";
3
+ import { forwardRef as t, useEffect as n, useId as r, useRef as i, useState as a } from "react";
4
+ import { jsx as o, jsxs as s } from "react/jsx-runtime";
5
+ var c = {
6
+ active: "_0J7mWW_active",
7
+ item: "_0J7mWW_item",
8
+ "level-2": "_0J7mWW_level-2",
9
+ "level-3": "_0J7mWW_level-3",
10
+ link: "_0J7mWW_link",
11
+ list: "_0J7mWW_list",
12
+ title: "_0J7mWW_title",
13
+ toc: "_0J7mWW_toc"
14
+ }, l = t(({ items: t, title: l = "On this page", activeId: u, defaultActiveId: d = null, onActiveChange: f, spy: p = !0, spyOffset: m = 0, onItemClick: h, className: g, "aria-label": _, ...v }, y) => {
15
+ let b = r(), x = u !== void 0, [S, C] = a(d), w = x ? u : S, T = i(null), E = i(void 0), D = i(f);
16
+ D.current = f;
17
+ let O = !x && p && t.length > 0, k = t.map((e) => e.id).join("\n");
18
+ if (n(() => {
19
+ if (!O) return;
20
+ let e = k.split("\n"), t = decodeURIComponent(window.location.hash.slice(1));
21
+ t && e.includes(t) && (T.current = t);
22
+ let n = (e) => {
23
+ e !== E.current && (E.current = e, C(e), D.current?.(e));
24
+ }, r = () => {
25
+ if (T.current) {
26
+ let e = document.getElementById(T.current)?.getBoundingClientRect().top;
27
+ if (e !== void 0 && e >= 0 && e <= window.innerHeight) {
28
+ n(T.current);
29
+ return;
30
+ }
31
+ T.current = null;
32
+ }
33
+ let t = null;
34
+ for (let n of e) {
35
+ let e = document.getElementById(n);
36
+ e && (t === null && (t = n), e.getBoundingClientRect().top <= m && (t = n));
37
+ }
38
+ t !== null && n(t);
39
+ }, i = () => {
40
+ T.current = null;
41
+ };
42
+ return r(), window.addEventListener("scroll", r, {
43
+ capture: !0,
44
+ passive: !0
45
+ }), window.addEventListener("resize", r), window.addEventListener("wheel", i, {
46
+ capture: !0,
47
+ passive: !0
48
+ }), window.addEventListener("touchmove", i, {
49
+ capture: !0,
50
+ passive: !0
51
+ }), window.addEventListener("keydown", i, { capture: !0 }), () => {
52
+ window.removeEventListener("scroll", r, { capture: !0 }), window.removeEventListener("resize", r), window.removeEventListener("wheel", i, { capture: !0 }), window.removeEventListener("touchmove", i, { capture: !0 }), window.removeEventListener("keydown", i, { capture: !0 });
53
+ };
54
+ }, [
55
+ O,
56
+ k,
57
+ m
58
+ ]), t.length === 0) return null;
59
+ let A = (e, t) => {
60
+ h?.(e, t), T.current = e.id, x || C(e.id), (x || E.current !== e.id) && (E.current = e.id, f?.(e.id));
61
+ }, j = l != null;
62
+ return /* @__PURE__ */ s("nav", {
63
+ ref: y,
64
+ className: e(c.toc, g),
65
+ "aria-labelledby": j && _ == null ? b : void 0,
66
+ "aria-label": _ ?? (j ? void 0 : "On this page"),
67
+ ...v,
68
+ children: [j && /* @__PURE__ */ o("p", {
69
+ id: b,
70
+ className: c.title,
71
+ children: l
72
+ }), /* @__PURE__ */ o("ul", {
73
+ className: c.list,
74
+ role: "list",
75
+ children: t.map((t) => {
76
+ let n = w === t.id;
77
+ return /* @__PURE__ */ o("li", {
78
+ className: e(c.item, c[`level-${t.level ?? 1}`]),
79
+ children: /* @__PURE__ */ o("a", {
80
+ href: t.href ?? `#${t.id}`,
81
+ className: e(c.link, n && c.active),
82
+ "aria-current": n ? "location" : void 0,
83
+ onClick: (e) => A(t, e),
84
+ children: t.title
85
+ })
86
+ }, t.id);
87
+ })
88
+ })]
89
+ });
90
+ });
91
+ l.displayName = "TableOfContents";
92
+ //#endregion
93
+ export { l as t };
@@ -0,0 +1,2 @@
1
+ import { t as e } from "../../TableOfContents-d26S7KYx.js";
2
+ export { e as TableOfContents };