@apliteni/apliteni-ui 0.50.0 → 0.52.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 +3 -0
- package/package.json +1 -1
- package/react/README.md +42 -1
- package/react/dist/index.css +7 -0
- package/react/dist/index.d.ts +20 -1
- package/react/dist/index.js +103 -7
- package/src/components/topbar.js +57 -24
- package/src/styles/topbar.css +1 -1
package/README.md
CHANGED
|
@@ -170,6 +170,9 @@ See Storybook's **Button / Busy Transition** for the live vanilla example.
|
|
|
170
170
|
|
|
171
171
|
## Theming
|
|
172
172
|
|
|
173
|
+
React pages can use [ThemeToggle and its pre-paint script](react/README.md#themetoggle)
|
|
174
|
+
to save dark, light or auto choices. Auto resolves to dark or light on the root.
|
|
175
|
+
|
|
173
176
|
Theme is a `data-theme="dark|light"` attribute on `<html>`; accent is an orthogonal
|
|
174
177
|
`data-accent` on top:
|
|
175
178
|
|
package/package.json
CHANGED
package/react/README.md
CHANGED
|
@@ -28,7 +28,7 @@ import '@apliteni/apliteni-ui/react/css'; // React components' shell styles (mo
|
|
|
28
28
|
import { DataTable, Modal, Button } from '@apliteni/apliteni-ui/react';
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
Components: `DataTable`, `Pagination`, `StatBand`, `Modal`, `Confirm`, `Drawer`, `CommandPalette`, `Dropdown`, `BackLink`, `Snippet`, `Tabs`, `Button`, `Badge`, `Card`, `Callout`, `Icon`.
|
|
31
|
+
Components: `EmptyState`, `DataTable`, `Pagination`, `StatBand`, `Modal`, `Confirm`, `Drawer`, `CommandPalette`, `Dropdown`, `BackLink`, `Snippet`, `Tabs`, `Button`, `Badge`, `Card`, `Callout`, `Icon`.
|
|
32
32
|
|
|
33
33
|
`Snippet` accepts `label`, `code`, `reveal`, and `copyLabel` props. It treats `code`
|
|
34
34
|
as plain text and copies it exactly as provided. After a successful clipboard write,
|
|
@@ -77,6 +77,13 @@ vanilla factory where the steps have to be real links. `PAGE_SIZES` and
|
|
|
77
77
|
either number. They are declared in this package's own types, and `PAGE_SIZES` is a
|
|
78
78
|
`readonly number[]`: pass it to `pageSizes`, but do not add sizes to it.
|
|
79
79
|
|
|
80
|
+
## EmptyState
|
|
81
|
+
|
|
82
|
+
`EmptyState` uses the vanilla layout. `variant` selects `first-run`, `no-matches`,
|
|
83
|
+
`not-found`, or `not-yet-built` copy; `title`, `sub`, and `icon` override it.
|
|
84
|
+
`art` accepts a vanilla illustration name, trusted SVG string, or React node; `actions` accepts React nodes. Use at most one primary action and one ghost
|
|
85
|
+
action. The page supplies its own h1; EmptyState uses vanilla’s div title.
|
|
86
|
+
|
|
80
87
|
## What the Modal does with focus
|
|
81
88
|
|
|
82
89
|
Opening moves focus to the first eligible control in the body, in DOM order, or to
|
|
@@ -434,6 +441,40 @@ function SaveButton() {
|
|
|
434
441
|
<Toast><SaveButton /></Toast>
|
|
435
442
|
```
|
|
436
443
|
|
|
444
|
+
## ThemeToggle
|
|
445
|
+
|
|
446
|
+
ThemeToggle uses the existing topbar button. It shows the current theme and the
|
|
447
|
+
action that will happen next. Add `labelled` to display the current theme beside
|
|
448
|
+
the button.
|
|
449
|
+
|
|
450
|
+
```tsx
|
|
451
|
+
import { ThemeToggle, THEME_INIT_SCRIPT } from '@apliteni/apliteni-ui/react';
|
|
452
|
+
|
|
453
|
+
<ThemeToggle />
|
|
454
|
+
<ThemeToggle labelled />
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
### Contract
|
|
458
|
+
|
|
459
|
+
| Surface | Contract |
|
|
460
|
+
| --- | --- |
|
|
461
|
+
| Props | `ThemeToggleProps` has one optional prop: `labelled?: boolean`, default `false`. There are no controlled-value or callback props. |
|
|
462
|
+
| Choices | Each press cycles `dark` → `light` → `auto` → `dark`. Auto shows a monitor icon and keeps its own label even when the resolved theme changes. |
|
|
463
|
+
| Storage | Explicit choices use localStorage key `apliteni-strategy-theme`. Existing `dark` and `light` values work without migration. `auto`, missing values and unknown values follow the OS; mounting does not rewrite storage. |
|
|
464
|
+
| DOM | On mount, the control sets `data-theme-choice` to dark, light or auto and `data-theme` to dark or light on `html`. Auto resolves to light when `(prefers-color-scheme: light)` matches, otherwise dark. An existing `data-theme-choice` takes precedence on mount; unknown attribute values become auto. |
|
|
465
|
+
| Updates | Mounted controls follow OS changes in auto mode and storage changes from other tabs. A press dispatches a plain `apliteni-theme-choice` event on `window`, with no payload; read the root attributes for the choice and resolved theme. OS and storage updates do not dispatch that event. |
|
|
466
|
+
| Storage failure | The button still works and retains its choice on the current page. Persistence across reloads requires working storage. |
|
|
467
|
+
| Before paint | `THEME_INIT_SCRIPT: string` reads the same stored values and sets only `data-theme`. It neither writes storage nor subscribes to OS changes; mounted controls handle later updates. Server rendering starts with the Auto label and does not set root attributes. |
|
|
468
|
+
|
|
469
|
+
Place `THEME_INIT_SCRIPT` in an inline `<head>` script before styles load so the theme
|
|
470
|
+
is set before the first paint. Allow this script through your Content Security Policy
|
|
471
|
+
with a nonce or hash. The component applies the preference when React mounts, but it
|
|
472
|
+
cannot change an earlier paint.
|
|
473
|
+
|
|
474
|
+
```tsx
|
|
475
|
+
<script nonce={nonce} dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
|
|
476
|
+
```
|
|
477
|
+
|
|
437
478
|
## AppShell
|
|
438
479
|
|
|
439
480
|
AppShell includes the kit’s rail and top band. Pass the current router pathname so the longest matching section path becomes active. Spread the props from `renderLink` onto your router link. The rail’s folded state is saved in a shared cookie. On phones, the bar shows up to four sections. If there are more, it shows three sections and a More sheet. Provide no more than one primary page action. The palette defaults to section links; pass `palette` to add custom commands. Import both the kit CSS and React CSS.
|
package/react/dist/index.css
CHANGED
package/react/dist/index.d.ts
CHANGED
|
@@ -586,7 +586,26 @@ type AppShellProps = {
|
|
|
586
586
|
};
|
|
587
587
|
declare function AppShell({ sections, pathname, title, children, word, brand, brandHref, width, back, lede, actions, bandControl, account, onSignOut, defaultCollapsed, palette, renderLink }: AppShellProps): react.JSX.Element;
|
|
588
588
|
|
|
589
|
+
type EmptyStateVariant = 'first-run' | 'no-matches' | 'not-found' | 'not-yet-built';
|
|
590
|
+
type EmptyStateProps = {
|
|
591
|
+
variant?: EmptyStateVariant;
|
|
592
|
+
title?: string;
|
|
593
|
+
sub?: string;
|
|
594
|
+
icon?: string;
|
|
595
|
+
art?: ReactNode;
|
|
596
|
+
actions?: ReactNode;
|
|
597
|
+
};
|
|
598
|
+
declare function EmptyState({ variant, title, sub, icon, art, actions }: EmptyStateProps): react.JSX.Element;
|
|
599
|
+
|
|
600
|
+
/** Place in a head script before styles load to apply the theme before first paint. */
|
|
601
|
+
declare const THEME_INIT_SCRIPT = "(()=>{let t;try{t=localStorage.getItem('apliteni-strategy-theme')}catch{}document.documentElement.setAttribute('data-theme',t==='dark'||t==='light'?t:window.matchMedia?.('(prefers-color-scheme: light)').matches?'light':'dark')})()";
|
|
602
|
+
type ThemeToggleProps = {
|
|
603
|
+
/** Show the current theme beside the icon. */
|
|
604
|
+
labelled?: boolean;
|
|
605
|
+
};
|
|
606
|
+
declare function ThemeToggle({ labelled }: ThemeToggleProps): react.JSX.Element;
|
|
607
|
+
|
|
589
608
|
declare const PAGE_SIZES: readonly number[];
|
|
590
609
|
declare const DEFAULT_PAGE_SIZE: number;
|
|
591
610
|
|
|
592
|
-
export { AppShell, type AppShellProps, type AppShellSection, BackLink, type BackLinkOwnProps, type BackLinkProps, Badge, BusyRegion, type BusyRegionProps, Button, type ButtonProps, Callout, type CalloutProps, type CalloutVariant, Card, type Column, type CommandGroup, type CommandItem, CommandPalette, type CommandPaletteProps, Confirm, type ConfirmProps, DEFAULT_PAGE_SIZE, DataTable, type DataTableProps, DeltaValue, type DeltaValueProps, Denied, type DeniedProps, Drawer, type DrawerProps, DrawerSection, type DrawerSectionProps, Dropdown, type DropdownBadge, type DropdownEntry, type DropdownItem, type DropdownProps, type DropdownRowProps, type DropdownSearch, type DropdownSection, type DropdownSeparator, FileField, type FileFieldProps, type Filter, FilterBar, type FilterBarProps, Icon, KeyValueList, type KeyValueListProps, type KeyValueRow, Modal, type ModalProps, NumericValue, type NumericValueProps, PAGE_SIZES, Pagination, type PaginationProps, RowIdentity, type RowIdentityProps, type SegmentOption, Segmented, type SegmentedProps, SelectField, type SelectFieldProps, Skeleton, type SkeletonProps, SkeletonTable, type SkeletonTableProps, Snippet, type SnippetProps, StatBand, type StatBandProps, type StatDelta, type StatFigure, type StatTone, type StatVariant, type TabItem, type TableSort, Tabs, type TabsProps, TextArea, type TextAreaProps, TextField, type TextFieldProps, Timeline, type TimelineEvent, type TimelineProps, Toast, type ToastNotice, type ToastProps, sortTableRows, useToast };
|
|
611
|
+
export { AppShell, type AppShellProps, type AppShellSection, BackLink, type BackLinkOwnProps, type BackLinkProps, Badge, BusyRegion, type BusyRegionProps, Button, type ButtonProps, Callout, type CalloutProps, type CalloutVariant, Card, type Column, type CommandGroup, type CommandItem, CommandPalette, type CommandPaletteProps, Confirm, type ConfirmProps, DEFAULT_PAGE_SIZE, DataTable, type DataTableProps, DeltaValue, type DeltaValueProps, Denied, type DeniedProps, Drawer, type DrawerProps, DrawerSection, type DrawerSectionProps, Dropdown, type DropdownBadge, type DropdownEntry, type DropdownItem, type DropdownProps, type DropdownRowProps, type DropdownSearch, type DropdownSection, type DropdownSeparator, EmptyState, type EmptyStateProps, type EmptyStateVariant, FileField, type FileFieldProps, type Filter, FilterBar, type FilterBarProps, Icon, KeyValueList, type KeyValueListProps, type KeyValueRow, Modal, type ModalProps, NumericValue, type NumericValueProps, PAGE_SIZES, Pagination, type PaginationProps, RowIdentity, type RowIdentityProps, type SegmentOption, Segmented, type SegmentedProps, SelectField, type SelectFieldProps, Skeleton, type SkeletonProps, SkeletonTable, type SkeletonTableProps, Snippet, type SnippetProps, StatBand, type StatBandProps, type StatDelta, type StatFigure, type StatTone, type StatVariant, THEME_INIT_SCRIPT, type TabItem, type TableSort, Tabs, type TabsProps, TextArea, type TextAreaProps, TextField, type TextFieldProps, ThemeToggle, type ThemeToggleProps, Timeline, type TimelineEvent, type TimelineProps, Toast, type ToastNotice, type ToastProps, sortTableRows, useToast };
|
package/react/dist/index.js
CHANGED
|
@@ -51,7 +51,7 @@ var cx = (...a) => a.filter(Boolean).join(" ");
|
|
|
51
51
|
var Button = forwardRef(function Button2({
|
|
52
52
|
variant = "secondary",
|
|
53
53
|
size = "md",
|
|
54
|
-
icon:
|
|
54
|
+
icon: icon6,
|
|
55
55
|
iconRight,
|
|
56
56
|
iconOnly,
|
|
57
57
|
block,
|
|
@@ -107,7 +107,7 @@ var Button = forwardRef(function Button2({
|
|
|
107
107
|
iconOnly && "ui-btn--icon"
|
|
108
108
|
);
|
|
109
109
|
const labelled = rest["aria-label"] != null || rest["aria-labelledby"] != null;
|
|
110
|
-
const fallback = typeof children === "string" && children.trim() ? children.trim() :
|
|
110
|
+
const fallback = typeof children === "string" && children.trim() ? children.trim() : icon6;
|
|
111
111
|
const named = iconOnly && !labelled && fallback ? { "aria-label": fallback, title: rest.title ?? fallback } : {};
|
|
112
112
|
return /* @__PURE__ */ jsxs(
|
|
113
113
|
"button",
|
|
@@ -141,7 +141,7 @@ var Button = forwardRef(function Button2({
|
|
|
141
141
|
if (!blockKey(event)) onKeyUp?.(event);
|
|
142
142
|
},
|
|
143
143
|
children: [
|
|
144
|
-
|
|
144
|
+
icon6 && /* @__PURE__ */ jsx2(Icon, { name: icon6 }),
|
|
145
145
|
!iconOnly && renderedChildren != null && /* @__PURE__ */ jsx2("span", { className: "ui-btn__label-slot", children: /* @__PURE__ */ jsx2("span", { className: "ui-btn__label", ref: labelRef, children: renderedChildren }) }),
|
|
146
146
|
iconRight && /* @__PURE__ */ jsx2(Icon, { name: iconRight }),
|
|
147
147
|
busy && /* @__PURE__ */ jsxs("span", { className: "ui-btn__dots", "aria-hidden": "true", children: [
|
|
@@ -177,9 +177,9 @@ function Card({ title, sub, level = 2, children }) {
|
|
|
177
177
|
// src/primitives/Callout.tsx
|
|
178
178
|
import { calloutIcons, icon as kitIcon } from "@apliteni/apliteni-ui";
|
|
179
179
|
import { jsx as jsx5, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
180
|
-
function Callout({ variant, icon:
|
|
180
|
+
function Callout({ variant, icon: icon6 = calloutIcons[variant ?? "neutral"], children, actions }) {
|
|
181
181
|
return /* @__PURE__ */ jsxs3("div", { className: `ui-callout${variant && variant !== "neutral" ? ` ui-callout--${variant}` : ""}`, role: variant === "danger" ? "alert" : void 0, children: [
|
|
182
|
-
/* @__PURE__ */ jsx5("span", { className: "ui-callout__icon", dangerouslySetInnerHTML: { __html: kitIcon(
|
|
182
|
+
/* @__PURE__ */ jsx5("span", { className: "ui-callout__icon", dangerouslySetInnerHTML: { __html: kitIcon(icon6) } }),
|
|
183
183
|
/* @__PURE__ */ jsxs3("div", { className: "ui-callout__body", children: [
|
|
184
184
|
children,
|
|
185
185
|
actions != null && /* @__PURE__ */ jsx5("div", { className: "ui-toolbar ui-callout__actions", children: actions })
|
|
@@ -1213,12 +1213,12 @@ function Denied({
|
|
|
1213
1213
|
title = "You don\u2019t have access",
|
|
1214
1214
|
sub,
|
|
1215
1215
|
need,
|
|
1216
|
-
icon:
|
|
1216
|
+
icon: icon6 = "lock",
|
|
1217
1217
|
className,
|
|
1218
1218
|
children
|
|
1219
1219
|
}) {
|
|
1220
1220
|
return /* @__PURE__ */ jsxs11("div", { className: cx4("ui-denied", className), children: [
|
|
1221
|
-
/* @__PURE__ */ jsx13("div", { className: "ui-denied__seal", children: /* @__PURE__ */ jsx13(Icon, { name:
|
|
1221
|
+
/* @__PURE__ */ jsx13("div", { className: "ui-denied__seal", children: /* @__PURE__ */ jsx13(Icon, { name: icon6 }) }),
|
|
1222
1222
|
/* @__PURE__ */ jsx13("div", { className: "ui-denied__title", children: title }),
|
|
1223
1223
|
sub && /* @__PURE__ */ jsx13("div", { className: "ui-denied__sub", children: sub }),
|
|
1224
1224
|
need && /* @__PURE__ */ jsxs11("div", { className: "ui-denied__need", children: [
|
|
@@ -2312,6 +2312,99 @@ function AppShell({
|
|
|
2312
2312
|
] });
|
|
2313
2313
|
}
|
|
2314
2314
|
|
|
2315
|
+
// src/EmptyState.tsx
|
|
2316
|
+
import { icon as kitIcon2, illo } from "@apliteni/apliteni-ui";
|
|
2317
|
+
import { jsx as jsx26, jsxs as jsxs22 } from "react/jsx-runtime";
|
|
2318
|
+
var cases = {
|
|
2319
|
+
"first-run": { title: "Nothing needs you yet", sub: "New items will appear here when they need your attention.", icon: "check" },
|
|
2320
|
+
"no-matches": { title: "No results in this view", sub: "Try another search or clear your filters.", icon: "search" },
|
|
2321
|
+
"not-found": { title: "We can't find that page", sub: "Check the address or return to the home page.", icon: "folder" },
|
|
2322
|
+
"not-yet-built": { title: "This ships later", sub: "This screen is not available yet. Check back later.", icon: "clock" }
|
|
2323
|
+
};
|
|
2324
|
+
function EmptyState({ variant = "first-run", title, sub, icon: icon6, art, actions }) {
|
|
2325
|
+
const preset = cases[variant];
|
|
2326
|
+
const heading = title ?? preset.title;
|
|
2327
|
+
const guidance = sub ?? preset.sub;
|
|
2328
|
+
const glyph = icon6 ?? preset.icon;
|
|
2329
|
+
return /* @__PURE__ */ jsxs22("div", { className: "ui-empty", children: [
|
|
2330
|
+
art ? typeof art === "string" ? /* @__PURE__ */ jsx26("div", { className: "ui-empty__art", dangerouslySetInnerHTML: { __html: /<svg/.test(art) ? art : illo(art) } }) : /* @__PURE__ */ jsx26("div", { className: "ui-empty__art", "aria-hidden": "true", children: art }) : glyph ? /* @__PURE__ */ jsx26("div", { className: "ui-empty__icon", dangerouslySetInnerHTML: { __html: kitIcon2(glyph) } }) : null,
|
|
2331
|
+
heading && /* @__PURE__ */ jsx26("div", { className: "ui-empty__title", children: heading }),
|
|
2332
|
+
guidance && /* @__PURE__ */ jsx26("div", { className: "ui-empty__sub", children: guidance }),
|
|
2333
|
+
actions && /* @__PURE__ */ jsx26("div", { className: "ui-empty__actions", children: actions })
|
|
2334
|
+
] });
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
// src/ThemeToggle.tsx
|
|
2338
|
+
import { useSyncExternalStore } from "react";
|
|
2339
|
+
import { icon as icon5 } from "@apliteni/apliteni-ui";
|
|
2340
|
+
import { jsx as jsx27, jsxs as jsxs23 } from "react/jsx-runtime";
|
|
2341
|
+
var STORAGE_KEY = "apliteni-strategy-theme";
|
|
2342
|
+
var CHOICE_EVENT = "apliteni-theme-choice";
|
|
2343
|
+
var THEME_INIT_SCRIPT = `(()=>{let t;try{t=localStorage.getItem('apliteni-strategy-theme')}catch{}document.documentElement.setAttribute('data-theme',t==='dark'||t==='light'?t:window.matchMedia?.('(prefers-color-scheme: light)').matches?'light':'dark')})()`;
|
|
2344
|
+
function storedTheme() {
|
|
2345
|
+
try {
|
|
2346
|
+
const value = localStorage.getItem(STORAGE_KEY);
|
|
2347
|
+
return value === "dark" || value === "light" || value === "auto" ? value : null;
|
|
2348
|
+
} catch {
|
|
2349
|
+
return snapshot();
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
function snapshot() {
|
|
2353
|
+
const choice = document.documentElement.getAttribute("data-theme-choice");
|
|
2354
|
+
return choice === "dark" || choice === "light" ? choice : "auto";
|
|
2355
|
+
}
|
|
2356
|
+
function subscribe(notify) {
|
|
2357
|
+
const root = document.documentElement;
|
|
2358
|
+
const media = window.matchMedia?.("(prefers-color-scheme: light)");
|
|
2359
|
+
const applyPreference = (choice) => {
|
|
2360
|
+
root.setAttribute("data-theme-choice", choice);
|
|
2361
|
+
root.setAttribute("data-theme", choice === "auto" ? media?.matches ? "light" : "dark" : choice);
|
|
2362
|
+
};
|
|
2363
|
+
const onSystem = () => {
|
|
2364
|
+
if (snapshot() === "auto") applyPreference("auto");
|
|
2365
|
+
};
|
|
2366
|
+
const onChoice = () => notify();
|
|
2367
|
+
const onStorage = (event) => {
|
|
2368
|
+
if (event.key !== null && event.key !== STORAGE_KEY) return;
|
|
2369
|
+
applyPreference(storedTheme() ?? "auto");
|
|
2370
|
+
};
|
|
2371
|
+
const observer = new MutationObserver(notify);
|
|
2372
|
+
observer.observe(root, { attributes: true, attributeFilter: ["data-theme", "data-theme-choice"] });
|
|
2373
|
+
applyPreference(root.hasAttribute("data-theme-choice") ? snapshot() : storedTheme() ?? "auto");
|
|
2374
|
+
media?.addEventListener("change", onSystem);
|
|
2375
|
+
window.addEventListener(CHOICE_EVENT, onChoice);
|
|
2376
|
+
window.addEventListener("storage", onStorage);
|
|
2377
|
+
return () => {
|
|
2378
|
+
observer.disconnect();
|
|
2379
|
+
media?.removeEventListener("change", onSystem);
|
|
2380
|
+
window.removeEventListener(CHOICE_EVENT, onChoice);
|
|
2381
|
+
window.removeEventListener("storage", onStorage);
|
|
2382
|
+
};
|
|
2383
|
+
}
|
|
2384
|
+
function ThemeToggle({ labelled = false }) {
|
|
2385
|
+
const theme = useSyncExternalStore(subscribe, snapshot, () => "auto");
|
|
2386
|
+
const next = theme === "dark" ? "light" : theme === "light" ? "auto" : "dark";
|
|
2387
|
+
const label = theme === "auto" ? "Auto" : theme === "light" ? "Light" : "Dark";
|
|
2388
|
+
const name = `Theme: ${label}. Switch to ${next}.`;
|
|
2389
|
+
const toggle = () => {
|
|
2390
|
+
document.documentElement.setAttribute("data-theme-choice", next);
|
|
2391
|
+
document.documentElement.setAttribute("data-theme", next === "auto" ? window.matchMedia?.("(prefers-color-scheme: light)").matches ? "light" : "dark" : next);
|
|
2392
|
+
try {
|
|
2393
|
+
localStorage.setItem(STORAGE_KEY, next);
|
|
2394
|
+
} catch {
|
|
2395
|
+
}
|
|
2396
|
+
window.dispatchEvent(new Event(CHOICE_EVENT));
|
|
2397
|
+
};
|
|
2398
|
+
const button = /* @__PURE__ */ jsx27("button", { type: "button", className: "toggle", "aria-label": name, title: name, onClick: toggle, children: /* @__PURE__ */ jsx27("span", { className: "ic", "aria-hidden": "true", dangerouslySetInnerHTML: { __html: icon5(theme === "auto" ? "monitor" : theme === "light" ? "sun" : "moon") } }) });
|
|
2399
|
+
return labelled ? /* @__PURE__ */ jsxs23("span", { className: "ui-theme-toggle", children: [
|
|
2400
|
+
button,
|
|
2401
|
+
/* @__PURE__ */ jsxs23("span", { children: [
|
|
2402
|
+
label,
|
|
2403
|
+
" theme"
|
|
2404
|
+
] })
|
|
2405
|
+
] }) : button;
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2315
2408
|
// src/index.ts
|
|
2316
2409
|
var PAGE_SIZES = KIT_PAGE_SIZES;
|
|
2317
2410
|
var DEFAULT_PAGE_SIZE3 = KIT_DEFAULT_PAGE_SIZE;
|
|
@@ -2332,6 +2425,7 @@ export {
|
|
|
2332
2425
|
Drawer,
|
|
2333
2426
|
DrawerSection,
|
|
2334
2427
|
Dropdown,
|
|
2428
|
+
EmptyState,
|
|
2335
2429
|
FileField,
|
|
2336
2430
|
FilterBar,
|
|
2337
2431
|
Icon,
|
|
@@ -2347,9 +2441,11 @@ export {
|
|
|
2347
2441
|
SkeletonTable,
|
|
2348
2442
|
Snippet,
|
|
2349
2443
|
StatBand,
|
|
2444
|
+
THEME_INIT_SCRIPT,
|
|
2350
2445
|
Tabs,
|
|
2351
2446
|
TextArea,
|
|
2352
2447
|
TextField,
|
|
2448
|
+
ThemeToggle,
|
|
2353
2449
|
Timeline,
|
|
2354
2450
|
Toast,
|
|
2355
2451
|
sortTableRows,
|
package/src/components/topbar.js
CHANGED
|
@@ -8,22 +8,10 @@ import { accountMenuNav, initials, toMenuTuple } from './account-nav.js';
|
|
|
8
8
|
import { trustedAttr, trustedUrl } from '../html.js';
|
|
9
9
|
const THEME_KEY = 'apliteni-strategy-theme';
|
|
10
10
|
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
// reads the same way, and topbar stories render directly beneath it.
|
|
16
|
-
export const themeIcon = (t) => (t === 'light' ? sun : moon);
|
|
17
|
-
|
|
18
|
-
// The name carries both the state and what the click does, which is why there
|
|
19
|
-
// is no aria-pressed and no role="switch": dark and light are peers, not on and
|
|
20
|
-
// off, and an ARIA state on top of this name would announce the theme twice.
|
|
21
|
-
// The button is icon-only, so `title` is the only sighted reading of the same
|
|
22
|
-
// fact and carries the identical string — nothing for WCAG 2.5.3 to disagree
|
|
23
|
-
// with. Both are rewritten by applyTheme on every flip; a name that is right
|
|
24
|
-
// once and never again tells a screen-reader user nothing.
|
|
25
|
-
export const themeName = (t) =>
|
|
26
|
-
(t === 'light' ? 'Theme: Light. Switch to dark.' : 'Theme: Dark. Switch to light.');
|
|
11
|
+
// The icon and name show the saved choice, including automatic mode.
|
|
12
|
+
export const themeIcon = (t) => t === 'auto' ? icon('monitor') : t === 'light' ? sun : moon;
|
|
13
|
+
export const themeName = (t) => t === 'auto' ? 'Theme: Auto. Switch to dark.' :
|
|
14
|
+
t === 'light' ? 'Theme: Light. Switch to auto.' : 'Theme: Dark. Switch to light.';
|
|
27
15
|
|
|
28
16
|
export function themeToggle(theme = 'dark') {
|
|
29
17
|
// Single icon-only switch. Icon and name are pre-filled for `theme` so the
|
|
@@ -113,7 +101,9 @@ export function topbar({
|
|
|
113
101
|
|
|
114
102
|
// ---- Behaviours (call once after markup mounts) --------------------------
|
|
115
103
|
export function applyTheme(t, root = document.documentElement) {
|
|
116
|
-
root.
|
|
104
|
+
const media = root.ownerDocument.defaultView.matchMedia?.('(prefers-color-scheme: light)');
|
|
105
|
+
root.setAttribute('data-theme-choice', t);
|
|
106
|
+
root.setAttribute('data-theme', t === 'auto' ? (media?.matches ? 'light' : 'dark') : t);
|
|
117
107
|
const name = themeName(t);
|
|
118
108
|
root.querySelectorAll('[data-theme-toggle]').forEach((btn) => {
|
|
119
109
|
const ic = btn.querySelector('[data-theme-icon]');
|
|
@@ -124,6 +114,8 @@ export function applyTheme(t, root = document.documentElement) {
|
|
|
124
114
|
btn.setAttribute('title', name);
|
|
125
115
|
});
|
|
126
116
|
try { localStorage.setItem(THEME_KEY, t); } catch (e) { /* no-op */ }
|
|
117
|
+
const view = root.ownerDocument.defaultView;
|
|
118
|
+
view.dispatchEvent(new view.Event('apliteni-theme-choice'));
|
|
127
119
|
}
|
|
128
120
|
|
|
129
121
|
const ACCENT_KEY = 'apliteni-strategy-accent';
|
|
@@ -135,14 +127,55 @@ export function applyAccent(name, root = document.documentElement) {
|
|
|
135
127
|
try { localStorage.setItem(ACCENT_KEY, name || 'default'); } catch (e) { /* no-op */ }
|
|
136
128
|
}
|
|
137
129
|
|
|
130
|
+
const themeListeners = new WeakMap();
|
|
131
|
+
|
|
138
132
|
export function wireTopbar(root = document) {
|
|
139
|
-
|
|
140
|
-
root.querySelectorAll('[data-theme-toggle]')
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
133
|
+
const html = document.documentElement;
|
|
134
|
+
const buttons = root.querySelectorAll('[data-theme-toggle]');
|
|
135
|
+
if (buttons.length) {
|
|
136
|
+
let choice = html.getAttribute('data-theme-choice') || 'auto';
|
|
137
|
+
try { choice = localStorage.getItem(THEME_KEY) || choice; } catch { /* Use the page choice. */ }
|
|
138
|
+
if (!['dark', 'light', 'auto'].includes(choice)) choice = 'auto';
|
|
139
|
+
applyTheme(choice, html);
|
|
140
|
+
const media = html.ownerDocument.defaultView.matchMedia?.('(prefers-color-scheme: light)');
|
|
141
|
+
themeListeners.get(html)?.();
|
|
142
|
+
const onSystem = () => {
|
|
143
|
+
if (html.getAttribute('data-theme-choice') === 'auto') {
|
|
144
|
+
html.setAttribute('data-theme', media?.matches ? 'light' : 'dark');
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
media?.addEventListener('change', onSystem);
|
|
148
|
+
const updateButtons = () => {
|
|
149
|
+
const choice = html.getAttribute('data-theme-choice') || 'auto';
|
|
150
|
+
html.querySelectorAll('[data-theme-toggle]').forEach((btn) => {
|
|
151
|
+
btn.setAttribute('aria-label', themeName(choice));
|
|
152
|
+
btn.setAttribute('title', themeName(choice));
|
|
153
|
+
const glyph = btn.querySelector('[data-theme-icon]');
|
|
154
|
+
if (glyph) glyph.innerHTML = themeIcon(choice);
|
|
155
|
+
});
|
|
156
|
+
};
|
|
157
|
+
const observer = new html.ownerDocument.defaultView.MutationObserver(updateButtons);
|
|
158
|
+
observer.observe(html, { attributes: true, attributeFilter: ['data-theme-choice'] });
|
|
159
|
+
const view = html.ownerDocument.defaultView;
|
|
160
|
+
const onStorage = (event) => {
|
|
161
|
+
if (event.key !== null && event.key !== THEME_KEY) return;
|
|
162
|
+
let saved;
|
|
163
|
+
try { saved = localStorage.getItem(THEME_KEY); } catch { return; }
|
|
164
|
+
applyTheme(saved === 'dark' || saved === 'light' ? saved : 'auto', html);
|
|
165
|
+
};
|
|
166
|
+
view.addEventListener('storage', onStorage);
|
|
167
|
+
themeListeners.set(html, () => {
|
|
168
|
+
media?.removeEventListener('change', onSystem);
|
|
169
|
+
view.removeEventListener('storage', onStorage);
|
|
170
|
+
observer.disconnect();
|
|
171
|
+
});
|
|
172
|
+
buttons.forEach((btn) => {
|
|
173
|
+
btn.onclick = () => {
|
|
174
|
+
const current = html.getAttribute('data-theme-choice');
|
|
175
|
+
applyTheme(current === 'dark' ? 'light' : current === 'light' ? 'auto' : 'dark', html);
|
|
176
|
+
};
|
|
177
|
+
});
|
|
178
|
+
}
|
|
146
179
|
// Deck/Text view switch (.dtsw) — switch the active pill on click
|
|
147
180
|
root.querySelectorAll('.dtsw').forEach((sw) => {
|
|
148
181
|
sw.addEventListener('click', (e) => {
|
package/src/styles/topbar.css
CHANGED
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
border-color var(--dur-fast) var(--ease);
|
|
87
87
|
}
|
|
88
88
|
.toggle:hover { transform: translateY(-1px); border-color: var(--border-strong); }
|
|
89
|
-
.toggle .ic { width: 17px; height: 17px; display: inline-block; color: var(--
|
|
89
|
+
.toggle .ic { width: 17px; height: 17px; display: inline-block; color: var(--text); }
|
|
90
90
|
.toggle .ic svg { display: block; width: 100%; height: 100%; stroke-width: 2.2; }
|
|
91
91
|
|
|
92
92
|
/* Version switcher */
|