@milktop/react-ui 0.1.2 → 0.2.1

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.
Files changed (142) hide show
  1. package/README.md +57 -6
  2. package/dist/accordion/Accordion.d.ts +1 -1
  3. package/dist/accordion/Accordion.js +1 -1
  4. package/dist/action-bar/ActionBar.d.ts +1 -1
  5. package/dist/action-bar/ActionBar.js +1 -1
  6. package/dist/alert/Alert.d.ts +1 -1
  7. package/dist/alert/Alert.js +1 -1
  8. package/dist/app-shell/AppShell.d.ts +9 -1
  9. package/dist/app-shell/AppShell.js +46 -5
  10. package/dist/app-shell/Topbar.d.ts +1 -1
  11. package/dist/app-shell/Topbar.js +1 -1
  12. package/dist/app-shell/app-shell.css +5 -0
  13. package/dist/app-shell/shell.d.ts +2 -0
  14. package/dist/appearance-panel/Appearance.d.ts +39 -0
  15. package/dist/appearance-panel/Appearance.js +74 -0
  16. package/dist/appearance-panel/appearance.css +17 -0
  17. package/dist/appearance.d.ts +42 -0
  18. package/dist/appearance.js +157 -0
  19. package/dist/attachments/Attachments.d.ts +1 -1
  20. package/dist/attachments/Attachments.js +1 -1
  21. package/dist/avatar/Avatar.d.ts +1 -1
  22. package/dist/avatar/Avatar.js +1 -1
  23. package/dist/badge/Badge.d.ts +1 -1
  24. package/dist/badge/Badge.js +1 -1
  25. package/dist/banner/Banner.d.ts +1 -1
  26. package/dist/banner/Banner.js +1 -1
  27. package/dist/breadcrumbs/Breadcrumbs.d.ts +1 -1
  28. package/dist/breadcrumbs/Breadcrumbs.js +1 -1
  29. package/dist/button/Button.d.ts +3 -2
  30. package/dist/button/Button.js +1 -1
  31. package/dist/card/Card.d.ts +2 -2
  32. package/dist/card/Card.js +1 -1
  33. package/dist/checkbox/Checkbox.d.ts +1 -1
  34. package/dist/checkbox/Checkbox.js +1 -1
  35. package/dist/checkbox/CheckboxGroup.d.ts +1 -1
  36. package/dist/checkbox/CheckboxGroup.js +1 -1
  37. package/dist/checkbox/checkbox.css +1 -1
  38. package/dist/collapsible/Collapsible.d.ts +1 -1
  39. package/dist/collapsible/Collapsible.js +1 -1
  40. package/dist/command/Command.d.ts +1 -1
  41. package/dist/command/Command.js +1 -1
  42. package/dist/copy-button/CopyButton.d.ts +1 -1
  43. package/dist/copy-button/CopyButton.js +1 -1
  44. package/dist/data-list/DataList.d.ts +1 -1
  45. package/dist/data-list/DataList.js +1 -1
  46. package/dist/date-picker/DatePicker.d.ts +1 -1
  47. package/dist/date-picker/DatePicker.js +1 -1
  48. package/dist/date-picker/date-picker.css +6 -3
  49. package/dist/dialog/Dialog.d.ts +1 -1
  50. package/dist/dialog/Dialog.js +1 -1
  51. package/dist/editable/Editable.d.ts +1 -1
  52. package/dist/editable/Editable.js +1 -1
  53. package/dist/editor/Editor.d.ts +1 -1
  54. package/dist/editor/Editor.js +1 -1
  55. package/dist/editor/editor.css +2 -1
  56. package/dist/empty-state/EmptyState.d.ts +1 -1
  57. package/dist/empty-state/EmptyState.js +1 -1
  58. package/dist/event-calendar/EventCalendar.d.ts +12 -5
  59. package/dist/event-calendar/EventCalendar.js +155 -41
  60. package/dist/event-calendar/event-calendar.css +10 -5
  61. package/dist/file-upload/FileUpload.d.ts +1 -1
  62. package/dist/file-upload/FileUpload.js +1 -1
  63. package/dist/format/messages.d.ts +12 -0
  64. package/dist/format/messages.js +12 -0
  65. package/dist/hover-card/hover-card.css +3 -1
  66. package/dist/icons.d.ts +1 -0
  67. package/dist/icons.js +7 -0
  68. package/dist/index.d.ts +2 -0
  69. package/dist/index.js +2 -0
  70. package/dist/input/Input.d.ts +1 -1
  71. package/dist/input/Input.js +1 -1
  72. package/dist/input/input.css +7 -1
  73. package/dist/menu/Menu.d.ts +1 -1
  74. package/dist/menu/Menu.js +1 -1
  75. package/dist/menu/menu.css +3 -1
  76. package/dist/native-select/NativeSelect.d.ts +1 -1
  77. package/dist/native-select/NativeSelect.js +1 -1
  78. package/dist/native-select/native-select.css +2 -1
  79. package/dist/number-input/NumberInput.d.ts +1 -1
  80. package/dist/number-input/NumberInput.js +1 -1
  81. package/dist/number-input/number-input.css +6 -3
  82. package/dist/page/Page.d.ts +4 -0
  83. package/dist/page/Page.js +4 -2
  84. package/dist/pagination/Pagination.d.ts +1 -1
  85. package/dist/pagination/Pagination.js +1 -1
  86. package/dist/password-input/PasswordInput.d.ts +1 -1
  87. package/dist/password-input/PasswordInput.js +1 -1
  88. package/dist/password-input/password-input.css +6 -3
  89. package/dist/pin-input/pin-input.css +2 -1
  90. package/dist/popover/Popover.d.ts +1 -1
  91. package/dist/popover/Popover.js +1 -1
  92. package/dist/popover/popover.css +3 -1
  93. package/dist/radio-group/RadioGroup.d.ts +1 -1
  94. package/dist/radio-group/RadioGroup.js +1 -1
  95. package/dist/radio-group/radio-group.css +1 -1
  96. package/dist/segment-group/Segmented.d.ts +1 -1
  97. package/dist/segment-group/Segmented.js +1 -1
  98. package/dist/select/ListSelect.d.ts +1 -1
  99. package/dist/select/ListSelect.js +1 -1
  100. package/dist/select/SearchSelect.d.ts +1 -1
  101. package/dist/select/SearchSelect.js +1 -1
  102. package/dist/select/search-select.css +6 -3
  103. package/dist/select/select.css +5 -2
  104. package/dist/sheet/Sheet.d.ts +1 -1
  105. package/dist/sheet/Sheet.js +1 -1
  106. package/dist/sidebar/NavGroup.d.ts +3 -2
  107. package/dist/sidebar/NavGroup.js +14 -4
  108. package/dist/sidebar/NavItem.d.ts +1 -1
  109. package/dist/sidebar/NavItem.js +1 -1
  110. package/dist/sidebar/NavSection.d.ts +1 -1
  111. package/dist/sidebar/NavSection.js +1 -1
  112. package/dist/sidebar/Sidebar.d.ts +1 -1
  113. package/dist/sidebar/Sidebar.js +1 -1
  114. package/dist/sidebar/SidebarUser.d.ts +1 -1
  115. package/dist/sidebar/SidebarUser.js +1 -1
  116. package/dist/sidebar/sidebar.css +75 -14
  117. package/dist/sortable-list/SortableList.d.ts +1 -1
  118. package/dist/sortable-list/SortableList.js +1 -1
  119. package/dist/stat/Stat.d.ts +1 -1
  120. package/dist/stat/Stat.js +1 -1
  121. package/dist/swatch-picker/SwatchPicker.d.ts +1 -1
  122. package/dist/swatch-picker/SwatchPicker.js +1 -1
  123. package/dist/swatch-picker/swatch-picker.css +3 -2
  124. package/dist/table/Table.d.ts +1 -1
  125. package/dist/table/Table.js +1 -1
  126. package/dist/tabs/Tabs.d.ts +1 -1
  127. package/dist/tabs/Tabs.js +1 -1
  128. package/dist/tags-input/TagsInput.d.ts +1 -1
  129. package/dist/tags-input/TagsInput.js +1 -1
  130. package/dist/tags-input/tags-input.css +6 -3
  131. package/dist/textarea/textarea.css +2 -1
  132. package/dist/theme-toggle/ThemeToggle.d.ts +1 -1
  133. package/dist/theme-toggle/ThemeToggle.js +1 -1
  134. package/dist/theme.css +51 -10
  135. package/dist/timeline/Timeline.d.ts +1 -1
  136. package/dist/timeline/Timeline.js +1 -1
  137. package/dist/toast/Toaster.d.ts +1 -1
  138. package/dist/toast/Toaster.js +1 -1
  139. package/dist/tooltip/tooltip.css +3 -1
  140. package/dist/tree-view/TreeView.d.ts +1 -1
  141. package/dist/tree-view/TreeView.js +1 -1
  142. package/package.json +7 -6
package/README.md CHANGED
@@ -52,7 +52,8 @@ So far:
52
52
  - Pickers: TimeInput · NativeSelect · SwatchPicker · Select (one component: searchable, server `load`, `onCreate`,
53
53
  multiple, tags; Combobox's parts for building one by hand) · DatePicker
54
54
  - Choices: Checkbox · CheckboxGroup · Switch · RadioGroup · Segmented ·
55
- ThemeToggle (`colorScheme`, `useColorScheme`)
55
+ ThemeToggle (`colorScheme`, `useColorScheme`) · Appearance (light and dark, the accent and the
56
+ sidebar style behind one button; `appearance`, `useAppearance`)
56
57
  - Forms: Field · Fields
57
58
  - Overlays: Tooltip · Popover · HoverCard · Dialog · Sheet (both: props or
58
59
  composed from Header/Body/Footer) · ActionBar · Menu · ContextMenu (Menu.Item,
@@ -65,7 +66,8 @@ So far:
65
66
  events, a date jump) · SortableList (drag or keyboard, on dnd-kit)
66
67
  - Disclosure: Tabs/Tab (with counts and dots, inline or in the corner) · Accordion · Collapsible
67
68
  - Navigation: AppShell (flat or inset; the sidebar collapses to a rail with ⌘B
68
- and is a drawer on phones) · Topbar · Sidebar (NavSection, NavGroup, NavItem,
69
+ and is a drawer on phones; inset, or `scroll="page"`, scrolls only the page
70
+ on wide screens) · Topbar · Sidebar (NavSection, NavGroup, NavItem,
69
71
  SidebarUser; also Sidebar.Section…) · Page · Breadcrumbs · Command (⌘K)
70
72
 
71
73
  That's all of svelte-ui's components.
@@ -104,7 +106,8 @@ the words of the app's provider.
104
106
  (`classes={{ itemText: 'font-mono' }}`).
105
107
  - Slots replace a part's content: a node, or a function given its state
106
108
  (`indicator={({ indeterminate }) => …}`, `renderItem={(item) => …}`).
107
- - `style={{ '--ui-accent': … }}`: tokens for one instance; on `:root` for the whole app.
109
+ - Tokens for one instance or a section: a class (`className="[--ui-accent:#16a34a]"` with
110
+ Tailwind) or `style={{ '--ui-accent': … }}`; on `:root` for the whole app.
108
111
  - Typed: written in TypeScript, shipped as JS with `.d.ts` types, so TSX apps
109
112
  get checked props (Select's value is the type of your items' `valueKey`)
110
113
  and JSX apps get autocomplete, with no errors either way.
@@ -133,6 +136,53 @@ dark values apply under `html.dark`:
133
136
  :root { --ui-accent: #e11d48; --ui-radius: 10px; }
134
137
  ```
135
138
 
139
+ ### Accent and sidebar style
140
+
141
+ `appearance` holds the accent colour and the sidebar style, saved like
142
+ `colorScheme`. `<Appearance>` lets people choose them, with light and dark,
143
+ behind one button:
144
+
145
+ ```tsx
146
+ <Appearance accents={['teal', 'blue', 'violet']} sidebars={['neutral', 'tint', 'dark']} />
147
+ ```
148
+
149
+ `accents` and `sidebars` are what users may choose (every one if left out),
150
+ the default first: a saved choice outside them moves to it. While building
151
+ the app, add `dev`: it offers every accent and sidebar style, any colour, a
152
+ reset, and the props to paste for the current choice. Take it off before
153
+ shipping.
154
+
155
+ The accents are Tailwind's scales (`slate`, `blue`, `indigo`, `violet`,
156
+ `teal`, `emerald`, `rose`); a custom hex colour gets its scale worked out in
157
+ the browser. It sets the `--ui-accent-50` … `-950` scale on `<html>`, which
158
+ the accent and ring tokens are steps of, so setting that scale in your own
159
+ CSS recolours them too. The sidebar styles (`default`, `neutral`, `soft`,
160
+ `tint`, `accent`, `dark`, `deep`) are light-mode only. From code:
161
+
162
+ ```ts
163
+ import { appearance } from '@milktop/react-ui'
164
+
165
+ appearance.defaults = { accent: 'teal', sidebar: 'neutral' } // before anything is saved, and for reset
166
+ appearance.update({ accent: 'violet', sidebar: 'dark' })
167
+ appearance.load(user.appearance) // apply a saved one
168
+ appearance.onChange = (value) => save(value) // { accent, custom, sidebar }
169
+ ```
170
+
171
+ It saves under `ui-appearance` (`appearance.storageKey` changes it), scale
172
+ included, so the `<head>` can paint it before your bundle loads:
173
+
174
+ ```html
175
+ <script>
176
+ try {
177
+ var a = JSON.parse(localStorage.getItem('ui-appearance') || '{}'), r = document.documentElement
178
+ if (a.sidebar && a.sidebar !== 'default') r.dataset.uiSidebarStyle = a.sidebar
179
+ var steps = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]
180
+ if (a.scale) steps.forEach(function (s, i) { r.style.setProperty('--ui-accent-' + s, a.scale[i]) })
181
+ if (a.scaleText) r.style.setProperty('--ui-accent-500-text', a.scaleText)
182
+ } catch (e) {}
183
+ </script>
184
+ ```
185
+
136
186
  ### React Server Components
137
187
 
138
188
  Not supported yet, and not needed in Vite apps. A server component can't
@@ -150,9 +200,10 @@ npm run build # the playground as a static site, in playground/dist
150
200
  npm run build:lib # the package: src/ to dist/ (JS, .d.ts, CSS); runs before npm publish
151
201
  ```
152
202
 
153
- Releasing (logged in with `npm login`, on a clean main): each runs the checks
154
- and the build, bumps the version, commits and tags it, pushes with the tag,
155
- then publishes.
203
+ Releasing (on a clean main): each runs the checks and the build, bumps the
204
+ version, commits and tags it, and pushes with the tag. The tag starts the
205
+ Publish workflow (`.github/workflows/publish.yml`), which publishes to npm
206
+ through Trusted publishing: no npm login or token needed.
156
207
 
157
208
  ```sh
158
209
  npm run release:patch # 0.1.0 → 0.1.1
@@ -24,7 +24,7 @@
24
24
  */
25
25
  import { type ReactNode } from 'react';
26
26
  import { Accordion as Ark } from '@ark-ui/react/accordion';
27
- import 'iconify-icon';
27
+ import '../icons.js';
28
28
  import { type Classes } from '../utils.js';
29
29
  import '../theme.css';
30
30
  import './accordion.css';
@@ -25,7 +25,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
25
25
  */
26
26
  import { createContext, useContext, useRef } from 'react';
27
27
  import { Accordion as Ark } from '@ark-ui/react/accordion';
28
- import 'iconify-icon';
28
+ import '../icons.js';
29
29
  import { cx, partsOf, useControllable, withParts } from '../utils.js';
30
30
  import '../theme.css';
31
31
  import './accordion.css';
@@ -18,7 +18,7 @@
18
18
  * - `label`: the toolbar's accessible name
19
19
  */
20
20
  import { type ComponentProps, type ReactNode } from 'react';
21
- import 'iconify-icon';
21
+ import '../icons.js';
22
22
  import { type Classes } from '../utils.js';
23
23
  import '../theme.css';
24
24
  import './action-bar.css';
@@ -20,7 +20,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
20
20
  */
21
21
  import { useEffect, useState } from 'react';
22
22
  import { Portal } from '@ark-ui/react/portal';
23
- import 'iconify-icon';
23
+ import '../icons.js';
24
24
  import { cx, partsOf } from '../utils.js';
25
25
  import { useMessages } from '../format/LocaleProvider.js';
26
26
  import '../theme.css';
@@ -13,7 +13,7 @@
13
13
  * when they appear; the others are role=status.
14
14
  */
15
15
  import type { ComponentProps, ReactNode } from 'react';
16
- import 'iconify-icon';
16
+ import '../icons.js';
17
17
  import { type Classes } from '../utils.js';
18
18
  import '../theme.css';
19
19
  import './alert.css';
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import 'iconify-icon';
2
+ import '../icons.js';
3
3
  import { cx, partsOf } from '../utils.js';
4
4
  import { useMessages } from '../format/LocaleProvider.js';
5
5
  import '../theme.css';
@@ -10,6 +10,13 @@
10
10
  *
11
11
  * - `inset`: the page as a rounded panel on a frame, with a grey sidebar
12
12
  * whose hovered and current items go white (the `--ui-sidebar-inset-*` tokens)
13
+ * - `scroll`: what scrolls on wide screens: 'window' (default) or 'page',
14
+ * where the shell fills the viewport and only the page scrolls. `inset`
15
+ * always scrolls the page there. Phones scroll the window either way, so
16
+ * the browser can hide its toolbars. The page then takes focus, so Space
17
+ * and the arrow keys scroll it straight away, prints in full, and is marked
18
+ * `scroll-region` for Inertia's scroll resets. Listen for scrolling on the
19
+ * page (`useShell().scroller()` in the shell's parts), not the window.
13
20
  * - `collapsed`, `defaultCollapsed`; `onCollapsedChange`: the sidebar as an
14
21
  * icon rail
15
22
  * - `persist`: a localStorage key to remember `collapsed`
@@ -26,6 +33,7 @@ import '../theme.css';
26
33
  import './app-shell.css';
27
34
  export type AppShellProps = HTMLAttributes<HTMLDivElement> & {
28
35
  inset?: boolean;
36
+ scroll?: 'window' | 'page';
29
37
  collapsed?: boolean;
30
38
  defaultCollapsed?: boolean;
31
39
  onCollapsedChange?: (collapsed: boolean) => void;
@@ -34,5 +42,5 @@ export type AppShellProps = HTMLAttributes<HTMLDivElement> & {
34
42
  shortcut?: boolean;
35
43
  classes?: Classes<'root' | 'backdrop'>;
36
44
  };
37
- export declare function AppShell({ inset, collapsed: collapsedProp, defaultCollapsed, onCollapsedChange, persist, breakpoint, shortcut, className, classes, children, ...rest }: AppShellProps): import("react").JSX.Element;
45
+ export declare function AppShell({ inset, scroll, collapsed: collapsedProp, defaultCollapsed, onCollapsedChange, persist, breakpoint, shortcut, className, classes, children, ...rest }: AppShellProps): import("react").JSX.Element;
38
46
  export { useShell } from './shell.js';
@@ -11,6 +11,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
11
11
  *
12
12
  * - `inset`: the page as a rounded panel on a frame, with a grey sidebar
13
13
  * whose hovered and current items go white (the `--ui-sidebar-inset-*` tokens)
14
+ * - `scroll`: what scrolls on wide screens: 'window' (default) or 'page',
15
+ * where the shell fills the viewport and only the page scrolls. `inset`
16
+ * always scrolls the page there. Phones scroll the window either way, so
17
+ * the browser can hide its toolbars. The page then takes focus, so Space
18
+ * and the arrow keys scroll it straight away, prints in full, and is marked
19
+ * `scroll-region` for Inertia's scroll resets. Listen for scrolling on the
20
+ * page (`useShell().scroller()` in the shell's parts), not the window.
14
21
  * - `collapsed`, `defaultCollapsed`; `onCollapsedChange`: the sidebar as an
15
22
  * icon rail
16
23
  * - `persist`: a localStorage key to remember `collapsed`
@@ -21,7 +28,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
21
28
  * ⌘B (Ctrl+B) collapses the sidebar, or opens the drawer on phones; Escape
22
29
  * closes the drawer. Parts inside read the shell with `useShell()`.
23
30
  */
24
- import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react';
31
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
25
32
  import { cx, partsOf, useControllable } from '../utils.js';
26
33
  import { ShellContext } from './shell.js';
27
34
  import '../theme.css';
@@ -45,7 +52,29 @@ function useNarrow(breakpoint) {
45
52
  }, [query]);
46
53
  return useSyncExternalStore(subscribe, () => matchMedia(query).matches, () => false);
47
54
  }
48
- export function AppShell({ inset = false, collapsed: collapsedProp, defaultCollapsed = false, onCollapsedChange, persist = null, breakpoint = 768, shortcut = true, className, classes, children, ...rest }) {
55
+ // The keys that scroll the window do nothing for the page while nothing is
56
+ // focused, and Chrome ignores them on the page itself (focusable, see Page):
57
+ // they scroll it here, and focus it. Focus inside the page leaves them to
58
+ // the browser (fields, nested scrollers).
59
+ function scrollKey(e, page) {
60
+ if (![null, document.body, document.documentElement, page].includes(document.activeElement))
61
+ return;
62
+ if (e.altKey || e.ctrlKey || e.metaKey)
63
+ return;
64
+ const step = page.clientHeight * 0.875;
65
+ const top = {
66
+ ArrowDown: 40, ArrowUp: -40, PageDown: step, PageUp: -step,
67
+ ' ': e.shiftKey ? -step : step,
68
+ Home: -page.scrollTop, End: page.scrollHeight,
69
+ }[e.key];
70
+ if (top === undefined)
71
+ return;
72
+ e.preventDefault();
73
+ page.focus({ preventScroll: true });
74
+ const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
75
+ page.scrollBy({ top, behavior: still ? 'auto' : 'smooth' });
76
+ }
77
+ export function AppShell({ inset = false, scroll = 'window', collapsed: collapsedProp, defaultCollapsed = false, onCollapsedChange, persist = null, breakpoint = 768, shortcut = true, className, classes, children, ...rest }) {
49
78
  const part = partsOf('app-shell', classes);
50
79
  const [initial] = useState(() => saved(persist) ?? defaultCollapsed);
51
80
  const [collapsed, setCollapsed] = useControllable(collapsedProp, initial, onCollapsedChange);
@@ -54,6 +83,10 @@ export function AppShell({ inset = false, collapsed: collapsedProp, defaultColla
54
83
  // Widening the window past the breakpoint closes the drawer.
55
84
  const drawerOpen = drawer && mobile;
56
85
  const rail = collapsed && !mobile;
86
+ // Only the page scrolls (see `scroll`).
87
+ const pageScroll = !mobile && (inset || scroll === 'page');
88
+ const root = useRef(null);
89
+ const page = useCallback(() => root.current?.querySelector(':scope > [data-scope=page]') ?? null, []);
57
90
  const toggle = useCallback(() => {
58
91
  if (mobile)
59
92
  return setDrawer(!drawerOpen);
@@ -64,7 +97,10 @@ export function AppShell({ inset = false, collapsed: collapsedProp, defaultColla
64
97
  }
65
98
  catch { /* private mode */ }
66
99
  }, [mobile, drawerOpen, collapsed, setCollapsed, persist]);
67
- const shell = useMemo(() => ({ rail, mobile, drawerOpen, toggle, close: () => setDrawer(false) }), [rail, mobile, drawerOpen, toggle]);
100
+ const shell = useMemo(() => ({
101
+ rail, mobile, drawerOpen, pageScroll, toggle, close: () => setDrawer(false),
102
+ scroller: () => (pageScroll && page()) || document.scrollingElement,
103
+ }), [rail, mobile, drawerOpen, pageScroll, toggle, page]);
68
104
  // Where the main area starts, for things fixed to the viewport that centre
69
105
  // over it (ActionBar).
70
106
  useEffect(() => {
@@ -83,10 +119,15 @@ export function AppShell({ inset = false, collapsed: collapsedProp, defaultColla
83
119
  }
84
120
  else if (e.key === 'Escape' && drawerOpen)
85
121
  setDrawer(false);
122
+ else if (pageScroll && !e.defaultPrevented) {
123
+ const el = page();
124
+ if (el)
125
+ scrollKey(e, el);
126
+ }
86
127
  }
87
128
  addEventListener('keydown', keydown);
88
129
  return () => removeEventListener('keydown', keydown);
89
- }, [shortcut, toggle, drawerOpen]);
90
- return (_jsx(ShellContext, { value: shell, children: _jsxs("div", { ...rest, ...part('root'), className: cx(className, classes?.root), "data-inset": inset || undefined, "data-rail": rail || undefined, "data-mobile": mobile || undefined, children: [children, drawerOpen && _jsx("div", { ...part('backdrop'), role: "presentation", onClick: () => setDrawer(false) })] }) }));
130
+ }, [shortcut, toggle, drawerOpen, pageScroll, page]);
131
+ return (_jsx(ShellContext, { value: shell, children: _jsxs("div", { ref: root, ...rest, ...part('root'), className: cx(className, classes?.root), "data-inset": inset || undefined, "data-page-scroll": pageScroll || undefined, "data-rail": rail || undefined, "data-mobile": mobile || undefined, children: [children, drawerOpen && _jsx("div", { ...part('backdrop'), role: "presentation", onClick: () => setDrawer(false) })] }) }));
91
132
  }
92
133
  export { useShell } from './shell.js';
@@ -9,7 +9,7 @@
9
9
  * - `end`: actions on the right
10
10
  */
11
11
  import type { HTMLAttributes, ReactNode } from 'react';
12
- import 'iconify-icon';
12
+ import '../icons.js';
13
13
  import { type Classes } from '../utils.js';
14
14
  import '../theme.css';
15
15
  import './app-shell.css';
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import 'iconify-icon';
2
+ import '../icons.js';
3
3
  import { cx, partsOf } from '../utils.js';
4
4
  import { useMessages } from '../format/LocaleProvider.js';
5
5
  import { useShell } from './shell.js';
@@ -12,6 +12,11 @@
12
12
  &[data-inset][data-mobile] > [data-scope=page] { margin: 0 0.5rem 0.5rem; }
13
13
  > [data-scope=topbar] { grid-area: topbar; }
14
14
  > [data-scope=page] { grid-area: main; }
15
+ /* Only the page scrolls; printing lets it run on. */
16
+ &[data-page-scroll] { height: 100dvh; overflow: hidden;
17
+ > [data-scope=page] { min-height: 0; overflow-y: auto; overscroll-behavior: contain; outline: none; }
18
+ @media print { height: auto; overflow: visible;
19
+ > [data-scope=page] { overflow-y: visible; } } }
15
20
 
16
21
  /* Inset: the page is a rounded panel on the frame, and the sidebar and top
17
22
  bar sit flat around it. The grey sidebar's items go white. */
@@ -2,6 +2,8 @@ export type Shell = {
2
2
  rail: boolean;
3
3
  mobile: boolean;
4
4
  drawerOpen: boolean;
5
+ pageScroll: boolean;
6
+ scroller: () => HTMLElement;
5
7
  toggle: () => void;
6
8
  close: () => void;
7
9
  };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Light or dark (`colorScheme`), the accent colour and the sidebar style
3
+ * (`appearance`), behind one button. The choices are saved.
4
+ *
5
+ * <Appearance accents={['teal', 'blue', 'violet']} sidebars={['neutral', 'tint', 'dark']} />
6
+ * <Appearance dev />
7
+ *
8
+ * - `accents`, `sidebars`: what users may choose, by name (every one if left
9
+ * out; see `accents` and `sidebarStyles`). A saved choice outside them moves
10
+ * to their first item, so put the default first.
11
+ * - `dev`: for trying themes out while building the app; take it off before
12
+ * shipping. Offers every accent and sidebar style whatever the lists say,
13
+ * any colour, a reset, and the `accents` and `sidebars` to paste for the
14
+ * current choice. Its switch turns dev mode off to preview what users get.
15
+ * - `schemes`: false leaves out light and dark (e.g. with a ThemeToggle
16
+ * elsewhere)
17
+ * - `placement`: where the panel opens ('bottom-end')
18
+ * - `labels`: words over the locale's `appearance` ones
19
+ * - `onChange`: called with appearance's value ({ accent, custom, sidebar })
20
+ * when the user changes it
21
+ */
22
+ import { type HTMLAttributes } from 'react';
23
+ import { type PopoverProps } from '../popover/Popover.js';
24
+ import { type AppearanceValue } from '../appearance.js';
25
+ import { type Classes } from '../utils.js';
26
+ import type { Messages } from '../format/messages.js';
27
+ import '../theme.css';
28
+ import './appearance.css';
29
+ export type AppearanceProps = Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> & {
30
+ accents?: string[] | null;
31
+ sidebars?: string[] | null;
32
+ dev?: boolean;
33
+ schemes?: boolean;
34
+ placement?: PopoverProps['placement'];
35
+ labels?: Partial<Messages['appearance']> | null;
36
+ onChange?: (value: AppearanceValue) => void;
37
+ classes?: Classes<'root' | 'trigger' | 'panel' | 'setting' | 'label' | 'hint' | 'custom' | 'dev' | 'config'>;
38
+ };
39
+ export declare function Appearance({ accents, sidebars, dev, schemes, placement, labels, onChange, className, classes, ...rest }: AppearanceProps): import("react").JSX.Element;
@@ -0,0 +1,74 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * Light or dark (`colorScheme`), the accent colour and the sidebar style
4
+ * (`appearance`), behind one button. The choices are saved.
5
+ *
6
+ * <Appearance accents={['teal', 'blue', 'violet']} sidebars={['neutral', 'tint', 'dark']} />
7
+ * <Appearance dev />
8
+ *
9
+ * - `accents`, `sidebars`: what users may choose, by name (every one if left
10
+ * out; see `accents` and `sidebarStyles`). A saved choice outside them moves
11
+ * to their first item, so put the default first.
12
+ * - `dev`: for trying themes out while building the app; take it off before
13
+ * shipping. Offers every accent and sidebar style whatever the lists say,
14
+ * any colour, a reset, and the `accents` and `sidebars` to paste for the
15
+ * current choice. Its switch turns dev mode off to preview what users get.
16
+ * - `schemes`: false leaves out light and dark (e.g. with a ThemeToggle
17
+ * elsewhere)
18
+ * - `placement`: where the panel opens ('bottom-end')
19
+ * - `labels`: words over the locale's `appearance` ones
20
+ * - `onChange`: called with appearance's value ({ accent, custom, sidebar })
21
+ * when the user changes it
22
+ */
23
+ import { useEffect, useState } from 'react';
24
+ import { Popover } from '../popover/Popover.js';
25
+ import { Button } from '../button/Button.js';
26
+ import { Segmented } from '../segment-group/Segmented.js';
27
+ import { SwatchPicker } from '../swatch-picker/SwatchPicker.js';
28
+ import { NativeSelect } from '../native-select/NativeSelect.js';
29
+ import { Switch } from '../switch/Switch.js';
30
+ import { useColorScheme } from '../color-scheme.js';
31
+ import { appearance, accents as allAccents, sidebarStyles as allSidebars, useAppearance, } from '../appearance.js';
32
+ import { cx, partsOf } from '../utils.js';
33
+ import { useMessages } from '../format/LocaleProvider.js';
34
+ import '../theme.css';
35
+ import './appearance.css';
36
+ const schemeIcons = { system: 'lucide:monitor', light: 'lucide:sun', dark: 'lucide:moon' };
37
+ export function Appearance({ accents = null, sidebars = null, dev = false, schemes = true, placement = 'bottom-end', labels = null, onChange, className, classes, ...rest }) {
38
+ const t = useMessages();
39
+ const words = { ...t.appearance, ...labels };
40
+ const part = partsOf('appearance', classes);
41
+ const { scheme, dark, setScheme } = useColorScheme();
42
+ const value = useAppearance();
43
+ const [devMode, setDevMode] = useState(true);
44
+ const exploring = dev && devMode;
45
+ // In the lists' own order (the first is the fallback), or every one
46
+ const accentNames = exploring || !accents ? Object.keys(allAccents) : accents.filter((name) => name in allAccents);
47
+ const sidebarNames = exploring || !sidebars ? [...allSidebars] : sidebars.filter((name) => allSidebars.includes(name));
48
+ const schemeItems = ['system', 'light', 'dark'].map((s) => ({ value: s, label: t.themeToggle[s], icon: schemeIcons[s] }));
49
+ const accentItems = accentNames.map((name) => ({ value: name, label: words.accents[name] ?? name, color: allAccents[name][0][6] }));
50
+ const sidebarItems = sidebarNames.map((name) => ({ value: name, label: words.sidebars[name] ?? name }));
51
+ // A saved choice outside the lists moves to their first item.
52
+ const allowedAccents = accentNames.join();
53
+ const allowedSidebars = sidebarNames.join();
54
+ useEffect(() => {
55
+ if (!exploring)
56
+ appearance.restrict(allowedAccents.split(','), allowedSidebars.split(','));
57
+ }, [exploring, allowedAccents, allowedSidebars]);
58
+ // The current choice as code to paste into the app: the props that offer
59
+ // it, or for a custom colour, the default that sets it.
60
+ const config = value.custom
61
+ ? `appearance.defaults = ${JSON.stringify({ custom: value.custom, sidebar: value.sidebar })}`
62
+ : `accents={['${value.accent}']} sidebars={['${value.sidebar}']}`;
63
+ function pick(changes) {
64
+ appearance.update(changes);
65
+ onChange?.(appearance.value);
66
+ }
67
+ function reset() {
68
+ appearance.reset();
69
+ onChange?.(appearance.value);
70
+ }
71
+ return (_jsx("div", { ...rest, ...part('root'), className: cx(className, classes?.root), children: _jsx(Popover, { heading: words.label, placement: placement, trigger: _jsx(Button, { className: classes?.trigger, variant: "ghost", icon: "lucide:palette", "aria-label": words.label }), children: _jsxs("div", { ...part('panel'), children: [schemes && (_jsxs("div", { ...part('setting'), children: [_jsx("div", { ...part('label'), children: words.mode }), _jsx(Segmented, { size: "sm", items: schemeItems, value: scheme, "aria-label": words.mode, onValueChange: (next) => next && setScheme(next) })] })), _jsxs("div", { ...part('setting'), children: [_jsx("div", { ...part('label'), children: words.accent }), _jsx(SwatchPicker, { size: "sm", items: accentItems, value: value.custom ? null : value.accent, "aria-label": words.accent, onValueChange: (next) => next && pick({ accent: next, custom: null }) })] }), exploring && (_jsxs("label", { ...part('custom'), children: [_jsx("input", { type: "color", value: value.custom ?? appearance.scale[6], onChange: (e) => pick({ custom: e.target.value }) }), _jsx("span", { children: words.custom }), value.custom && _jsx("code", { children: value.custom })] })), _jsxs("div", { ...part('setting'), children: [_jsxs("div", { ...part('label'), children: [_jsx("span", { children: words.sidebar }), dark && _jsx("span", { ...part('hint'), children: words.lightOnly })] }), sidebarItems.length <= 4
72
+ ? _jsx(Segmented, { size: "sm", items: sidebarItems, value: value.sidebar, "aria-label": words.sidebar, onValueChange: (next) => next && pick({ sidebar: next }) })
73
+ : _jsx(NativeSelect, { size: "sm", items: sidebarItems, value: value.sidebar, "aria-label": words.sidebar, onValueChange: (next) => next && pick({ sidebar: next }) })] }), dev && (_jsxs("div", { ...part('dev'), children: [_jsx(Switch, { label: words.dev, checked: devMode, onCheckedChange: setDevMode }), exploring && _jsxs(_Fragment, { children: [_jsx("code", { ...part('config'), children: config }), _jsx(Button, { size: "sm", onClick: reset, children: words.reset })] })] }))] }) }) }));
74
+ }
@@ -0,0 +1,17 @@
1
+ @layer ui {
2
+ [data-scope=appearance][data-part=root] { display: inline-block;
3
+ /* A little wider than a popover's default, for four sidebar styles. */
4
+ & [data-scope=popover][data-part=content] { width: 20rem; } }
5
+ [data-scope=appearance][data-part=panel] { display: flex; flex-direction: column; gap: 1rem; }
6
+ [data-scope=appearance][data-part=label] { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem;
7
+ margin-bottom: 0.5rem; font-size: 12px; font-weight: 600; color: var(--ui-muted); }
8
+ [data-scope=appearance][data-part=hint] { font-weight: 400; }
9
+ [data-scope=appearance][data-part=custom] { display: flex; align-items: center; gap: 0.5rem; font-size: 14px; cursor: pointer;
10
+ & input { width: 2rem; height: 2rem; padding: 0; border: 1px solid var(--ui-border); border-radius: var(--ui-radius);
11
+ background: transparent; cursor: pointer; }
12
+ & code { font-size: 12px; color: var(--ui-muted); } }
13
+ [data-scope=appearance][data-part=dev] { display: flex; flex-direction: column; align-items: flex-start; gap: 0.75rem;
14
+ padding-top: 0.75rem; border-top: 1px solid var(--ui-border); }
15
+ [data-scope=appearance][data-part=config] { display: block; align-self: stretch; padding: 0.5rem; background: var(--ui-hover);
16
+ border-radius: var(--ui-radius); font-size: 12px; color: var(--ui-muted); white-space: pre-wrap; user-select: all; }
17
+ }
@@ -0,0 +1,42 @@
1
+ export declare const accentSteps: number[];
2
+ export declare const accents: {
3
+ slate: [string[], string];
4
+ blue: [string[], string];
5
+ indigo: [string[], string];
6
+ violet: [string[], string];
7
+ teal: [string[], string];
8
+ emerald: [string[], string];
9
+ rose: [string[], string];
10
+ };
11
+ export type AccentName = keyof typeof accents;
12
+ export declare const sidebarStyles: readonly ["default", "neutral", "soft", "tint", "accent", "dark", "deep"];
13
+ export type SidebarStyle = typeof sidebarStyles[number];
14
+ export type AppearanceValue = {
15
+ accent: AccentName;
16
+ custom: string | null;
17
+ sidebar: SidebarStyle;
18
+ };
19
+ export declare const accentScale: (hex: string) => string[];
20
+ declare class Appearance {
21
+ #private;
22
+ onChange: ((value: AppearanceValue) => void) | null;
23
+ constructor();
24
+ get storageKey(): string;
25
+ set storageKey(key: string);
26
+ get defaults(): AppearanceValue;
27
+ set defaults(value: Partial<AppearanceValue>);
28
+ get value(): AppearanceValue;
29
+ get accent(): "slate" | "blue" | "indigo" | "violet" | "teal" | "emerald" | "rose";
30
+ get custom(): string | null;
31
+ get sidebar(): "default" | "neutral" | "soft" | "tint" | "accent" | "dark" | "deep";
32
+ get scale(): string[];
33
+ get scaleText(): string;
34
+ update(changes: Partial<AppearanceValue>): void;
35
+ load(value: unknown): void;
36
+ reset(): void;
37
+ restrict(allowedAccents?: readonly string[] | null, allowedSidebars?: readonly string[] | null): void;
38
+ subscribe: (listener: () => void) => () => void;
39
+ }
40
+ export declare const appearance: Appearance;
41
+ export declare function useAppearance(): AppearanceValue;
42
+ export {};