zabi-components 8.1.0 → 9.0.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.
Files changed (273) hide show
  1. package/CHANGELOG.md +1011 -0
  2. package/README.md +1 -1
  3. package/THEME.md +18 -5
  4. package/THEMING.md +308 -74
  5. package/dist/atoms/ActionPanel.svelte +1 -1
  6. package/dist/atoms/Avatar.svelte +19 -3
  7. package/dist/atoms/Avatar.svelte.d.ts +12 -0
  8. package/dist/atoms/Badge.svelte +37 -8
  9. package/dist/atoms/Badge.svelte.d.ts +6 -0
  10. package/dist/atoms/Block.svelte +74 -0
  11. package/dist/atoms/Block.svelte.d.ts +29 -0
  12. package/dist/atoms/Button.svelte +52 -15
  13. package/dist/atoms/Button.svelte.d.ts +3 -2
  14. package/dist/atoms/Card.svelte +181 -25
  15. package/dist/atoms/Card.svelte.d.ts +55 -3
  16. package/dist/atoms/Checkbox.svelte +33 -0
  17. package/dist/atoms/Checkbox.svelte.d.ts +27 -0
  18. package/dist/atoms/Chip.svelte +351 -0
  19. package/dist/atoms/Chip.svelte.d.ts +57 -0
  20. package/dist/atoms/CodeField.svelte +385 -0
  21. package/dist/atoms/CodeField.svelte.d.ts +61 -0
  22. package/dist/atoms/ColorPicker.svelte +1 -0
  23. package/dist/atoms/DateField.svelte +41 -3
  24. package/dist/atoms/DateField.svelte.d.ts +31 -3
  25. package/dist/atoms/FloatingActionButton.svelte +5 -3
  26. package/dist/atoms/FloatingActionButton.svelte.d.ts +1 -1
  27. package/dist/atoms/Heading.svelte +27 -1
  28. package/dist/atoms/Heading.svelte.d.ts +7 -0
  29. package/dist/atoms/IconButton.svelte +81 -9
  30. package/dist/atoms/IconButton.svelte.d.ts +13 -0
  31. package/dist/atoms/Input.svelte +10 -5
  32. package/dist/atoms/Input.svelte.d.ts +3 -2
  33. package/dist/atoms/List.svelte +3 -2
  34. package/dist/atoms/ListItem.svelte +31 -7
  35. package/dist/atoms/ListItem.svelte.d.ts +6 -0
  36. package/dist/atoms/PickerField.svelte +239 -0
  37. package/dist/atoms/PickerField.svelte.d.ts +67 -0
  38. package/dist/atoms/Progress.svelte +78 -17
  39. package/dist/atoms/Progress.svelte.d.ts +11 -1
  40. package/dist/atoms/Radio.svelte +32 -0
  41. package/dist/atoms/Radio.svelte.d.ts +26 -0
  42. package/dist/atoms/Rating.svelte +132 -6
  43. package/dist/atoms/Rating.svelte.d.ts +24 -3
  44. package/dist/atoms/Select.svelte +24 -26
  45. package/dist/atoms/Select.svelte.d.ts +12 -0
  46. package/dist/atoms/SelectionControl.svelte +99 -6
  47. package/dist/atoms/SelectionControl.svelte.d.ts +25 -0
  48. package/dist/atoms/Skeleton.svelte +114 -18
  49. package/dist/atoms/Skeleton.svelte.d.ts +30 -3
  50. package/dist/atoms/SkeletonGroup.svelte +43 -0
  51. package/dist/atoms/SkeletonGroup.svelte.d.ts +20 -0
  52. package/dist/atoms/Slider.svelte +35 -0
  53. package/dist/atoms/Sparkline.svelte +109 -0
  54. package/dist/atoms/Sparkline.svelte.d.ts +27 -0
  55. package/dist/atoms/SpeechBubble.svelte +187 -0
  56. package/dist/atoms/SpeechBubble.svelte.d.ts +33 -0
  57. package/dist/atoms/Stat.svelte +72 -0
  58. package/dist/atoms/Stat.svelte.d.ts +19 -0
  59. package/dist/atoms/TemporalField.svelte +342 -10
  60. package/dist/atoms/TemporalField.svelte.d.ts +20 -4
  61. package/dist/atoms/Textarea.svelte +100 -5
  62. package/dist/atoms/Textarea.svelte.d.ts +21 -0
  63. package/dist/atoms/TimeColumn.svelte +150 -0
  64. package/dist/atoms/TimeColumn.svelte.d.ts +25 -0
  65. package/dist/atoms/TimeField.svelte +46 -3
  66. package/dist/atoms/TimeField.svelte.d.ts +34 -3
  67. package/dist/atoms/Toast.svelte +6 -5
  68. package/dist/atoms/Toggle.svelte +61 -10
  69. package/dist/atoms/Toggle.svelte.d.ts +15 -0
  70. package/dist/atoms/Tooltip.svelte +20 -1
  71. package/dist/atoms/Tooltip.svelte.d.ts +7 -0
  72. package/dist/atoms/index.d.ts +21 -0
  73. package/dist/atoms/index.js +17 -0
  74. package/dist/atoms/selection-control.styles.d.ts +15 -4
  75. package/dist/atoms/selection-control.styles.js +16 -5
  76. package/dist/components/atoms/index.d.ts +21 -0
  77. package/dist/components/atoms/index.d.ts.map +1 -1
  78. package/dist/components/atoms/selection-control.styles.d.ts +15 -4
  79. package/dist/components/atoms/selection-control.styles.d.ts.map +1 -1
  80. package/dist/components/index.d.ts +27 -3
  81. package/dist/components/index.d.ts.map +1 -1
  82. package/dist/components/map.d.ts +11 -0
  83. package/dist/components/map.d.ts.map +1 -0
  84. package/dist/components/molecules/index.d.ts +7 -2
  85. package/dist/components/molecules/index.d.ts.map +1 -1
  86. package/dist/components/molecules/toast-store.d.ts +30 -0
  87. package/dist/components/molecules/toast-store.d.ts.map +1 -1
  88. package/dist/components/organisms/index.d.ts +6 -1
  89. package/dist/components/organisms/index.d.ts.map +1 -1
  90. package/dist/components/types/variants.d.ts +12 -3
  91. package/dist/components/types/variants.d.ts.map +1 -1
  92. package/dist/components/util/app-shell.d.ts +55 -4
  93. package/dist/components/util/app-shell.d.ts.map +1 -1
  94. package/dist/components/util/avatar.d.ts +3 -0
  95. package/dist/components/util/avatar.d.ts.map +1 -1
  96. package/dist/components/util/bottom-tab-bar.d.ts +6 -0
  97. package/dist/components/util/bottom-tab-bar.d.ts.map +1 -1
  98. package/dist/components/util/chart.d.ts +140 -0
  99. package/dist/components/util/chart.d.ts.map +1 -0
  100. package/dist/components/util/chip.d.ts +66 -0
  101. package/dist/components/util/chip.d.ts.map +1 -0
  102. package/dist/components/util/cn.d.ts.map +1 -1
  103. package/dist/components/util/code-field.d.ts +63 -0
  104. package/dist/components/util/code-field.d.ts.map +1 -0
  105. package/dist/components/util/collapsible.d.ts +6 -0
  106. package/dist/components/util/collapsible.d.ts.map +1 -1
  107. package/dist/components/util/display-voice.d.ts +10 -0
  108. package/dist/components/util/display-voice.d.ts.map +1 -0
  109. package/dist/components/util/field-control.d.ts +30 -0
  110. package/dist/components/util/field-control.d.ts.map +1 -0
  111. package/dist/components/util/field.d.ts +6 -0
  112. package/dist/components/util/field.d.ts.map +1 -1
  113. package/dist/components/util/focus-utils.d.ts +2 -0
  114. package/dist/components/util/focus-utils.d.ts.map +1 -1
  115. package/dist/components/util/map-view.d.ts +144 -0
  116. package/dist/components/util/map-view.d.ts.map +1 -0
  117. package/dist/components/util/photo.d.ts +19 -0
  118. package/dist/components/util/photo.d.ts.map +1 -1
  119. package/dist/components/util/progress.d.ts +19 -0
  120. package/dist/components/util/progress.d.ts.map +1 -0
  121. package/dist/components/util/scroll-edge.d.ts +33 -0
  122. package/dist/components/util/scroll-edge.d.ts.map +1 -0
  123. package/dist/components/util/sheet-drag.d.ts +25 -1
  124. package/dist/components/util/sheet-drag.d.ts.map +1 -1
  125. package/dist/components/util/skeleton.d.ts +15 -0
  126. package/dist/components/util/skeleton.d.ts.map +1 -0
  127. package/dist/components/util/sliding-indicator.d.ts +25 -0
  128. package/dist/components/util/sliding-indicator.d.ts.map +1 -0
  129. package/dist/components/util/temporal-field.d.ts +89 -0
  130. package/dist/components/util/temporal-field.d.ts.map +1 -0
  131. package/dist/components/util/toaster.d.ts +40 -0
  132. package/dist/components/util/toaster.d.ts.map +1 -1
  133. package/dist/components/util/tone-surface.d.ts +34 -0
  134. package/dist/components/util/tone-surface.d.ts.map +1 -0
  135. package/dist/components/util/top-navbar.d.ts +26 -2
  136. package/dist/components/util/top-navbar.d.ts.map +1 -1
  137. package/dist/components/util/zabi-strings.d.ts +9 -0
  138. package/dist/components/util/zabi-strings.d.ts.map +1 -1
  139. package/dist/create-theme/cli.js +6 -2
  140. package/dist/create-theme/index.d.ts +17 -3
  141. package/dist/create-theme/index.js +252 -35
  142. package/dist/create-theme/lib/resolve.js +97 -0
  143. package/dist/create-theme/theme-data.js +1618 -90
  144. package/dist/index.d.ts +27 -3
  145. package/dist/index.js +18 -0
  146. package/dist/map.d.ts +10 -0
  147. package/dist/map.js +9 -0
  148. package/dist/molecules/Alert.svelte +31 -12
  149. package/dist/molecules/Alert.svelte.d.ts +12 -1
  150. package/dist/molecules/AppBar.svelte +287 -28
  151. package/dist/molecules/AppBar.svelte.d.ts +62 -0
  152. package/dist/molecules/AvatarGroup.svelte +1 -1
  153. package/dist/molecules/BarChart.svelte +381 -0
  154. package/dist/molecules/BarChart.svelte.d.ts +45 -0
  155. package/dist/molecules/BottomSheet.svelte +88 -20
  156. package/dist/molecules/BottomSheet.svelte.d.ts +17 -1
  157. package/dist/molecules/BottomTabBar.svelte +134 -28
  158. package/dist/molecules/BottomTabBar.svelte.d.ts +36 -2
  159. package/dist/molecules/Calendar.svelte.d.ts +1 -1
  160. package/dist/molecules/ChipGroup.svelte +325 -0
  161. package/dist/molecules/ChipGroup.svelte.d.ts +41 -0
  162. package/dist/molecules/Collapsible.svelte +158 -54
  163. package/dist/molecules/Collapsible.svelte.d.ts +23 -6
  164. package/dist/molecules/CollapsibleGroup.svelte +16 -6
  165. package/dist/molecules/ConfirmDialog.svelte +5 -1
  166. package/dist/molecules/ConfirmDialog.svelte.d.ts +3 -0
  167. package/dist/molecules/Drawer.svelte +33 -12
  168. package/dist/molecules/Dropdown.svelte +9 -1
  169. package/dist/molecules/Dropdown.svelte.d.ts +2 -0
  170. package/dist/molecules/DropdownItem.svelte +1 -1
  171. package/dist/molecules/ImageUpload.svelte +230 -120
  172. package/dist/molecules/ImageUpload.svelte.d.ts +38 -3
  173. package/dist/molecules/LineChart.svelte +459 -0
  174. package/dist/molecules/LineChart.svelte.d.ts +53 -0
  175. package/dist/molecules/MapView.svelte +565 -0
  176. package/dist/molecules/MapView.svelte.d.ts +88 -0
  177. package/dist/molecules/Modal.svelte +11 -6
  178. package/dist/molecules/PhotoGrid.svelte +100 -10
  179. package/dist/molecules/PhotoGrid.svelte.d.ts +11 -0
  180. package/dist/molecules/PhotoViewer.svelte +121 -24
  181. package/dist/molecules/PhotoViewer.svelte.d.ts +18 -0
  182. package/dist/molecules/PieChart.svelte +421 -0
  183. package/dist/molecules/PieChart.svelte.d.ts +51 -0
  184. package/dist/molecules/RadioGroup.svelte +20 -4
  185. package/dist/molecules/RadioGroup.svelte.d.ts +5 -0
  186. package/dist/molecules/SegmentedControl.svelte +136 -3
  187. package/dist/molecules/SegmentedControl.svelte.d.ts +6 -0
  188. package/dist/molecules/SidebarFooter.svelte +1 -1
  189. package/dist/molecules/SidebarNavSection.svelte +1 -1
  190. package/dist/molecules/SortableList.svelte +1 -0
  191. package/dist/molecules/Stepper.svelte +21 -7
  192. package/dist/molecules/StickyActionBar.svelte +67 -8
  193. package/dist/molecules/StickyActionBar.svelte.d.ts +14 -1
  194. package/dist/molecules/Tabs.svelte +79 -2
  195. package/dist/molecules/Toaster.svelte +171 -7
  196. package/dist/molecules/Toaster.svelte.d.ts +60 -2
  197. package/dist/molecules/ToasterToast.svelte +292 -60
  198. package/dist/molecules/ToasterToast.svelte.d.ts +6 -0
  199. package/dist/molecules/UnsavedChangesBar.svelte +13 -2
  200. package/dist/molecules/index.d.ts +7 -2
  201. package/dist/molecules/index.js +4 -0
  202. package/dist/molecules/toast-store.d.ts +30 -0
  203. package/dist/molecules/toast-store.js +4 -1
  204. package/dist/organisms/AppNavigation.svelte +440 -0
  205. package/dist/organisms/AppNavigation.svelte.d.ts +72 -0
  206. package/dist/organisms/AppShell.svelte +539 -39
  207. package/dist/organisms/AppShell.svelte.d.ts +102 -6
  208. package/dist/organisms/AppShellHeaderPart.svelte +163 -0
  209. package/dist/organisms/AppShellHeaderPart.svelte.d.ts +57 -0
  210. package/dist/organisms/SidebarNavigation.svelte +23 -36
  211. package/dist/organisms/SidebarPanel.svelte +10 -14
  212. package/dist/organisms/SidebarPanel.svelte.d.ts +1 -0
  213. package/dist/organisms/SidebarShell.svelte +2 -3
  214. package/dist/organisms/TopNavbar.svelte +117 -28
  215. package/dist/organisms/TopNavbar.svelte.d.ts +22 -1
  216. package/dist/organisms/TopNavbarSheetContent.svelte +145 -0
  217. package/dist/organisms/TopNavbarSheetContent.svelte.d.ts +24 -0
  218. package/dist/organisms/index.d.ts +6 -1
  219. package/dist/organisms/index.js +4 -0
  220. package/dist/types/components.d.ts +17 -1
  221. package/dist/types/components.d.ts.map +1 -1
  222. package/dist/types/index.d.ts +497 -10
  223. package/dist/types/index.d.ts.map +1 -1
  224. package/dist/types/variants.ts +14 -3
  225. package/dist/util/app-shell.d.ts +55 -4
  226. package/dist/util/app-shell.js +32 -7
  227. package/dist/util/avatar.d.ts +3 -0
  228. package/dist/util/bottom-tab-bar.d.ts +6 -0
  229. package/dist/util/chart.d.ts +139 -0
  230. package/dist/util/chart.js +305 -0
  231. package/dist/util/chip.d.ts +65 -0
  232. package/dist/util/chip.js +58 -0
  233. package/dist/util/cn.js +28 -2
  234. package/dist/util/code-field.d.ts +62 -0
  235. package/dist/util/code-field.js +112 -0
  236. package/dist/util/collapsible.d.ts +6 -0
  237. package/dist/util/display-voice.d.ts +9 -0
  238. package/dist/util/display-voice.js +9 -0
  239. package/dist/util/field-control.d.ts +29 -0
  240. package/dist/util/field-control.js +32 -0
  241. package/dist/util/field.d.ts +6 -0
  242. package/dist/util/focus-utils.d.ts +2 -0
  243. package/dist/util/focus-utils.js +4 -0
  244. package/dist/util/map-view.d.ts +143 -0
  245. package/dist/util/map-view.js +121 -0
  246. package/dist/util/photo.d.ts +19 -0
  247. package/dist/util/photo.js +3 -0
  248. package/dist/util/progress.d.ts +18 -0
  249. package/dist/util/progress.js +17 -0
  250. package/dist/util/scroll-edge.d.ts +32 -0
  251. package/dist/util/scroll-edge.js +87 -0
  252. package/dist/util/sheet-drag.d.ts +25 -1
  253. package/dist/util/sheet-drag.js +71 -1
  254. package/dist/util/skeleton.d.ts +14 -0
  255. package/dist/util/skeleton.js +22 -0
  256. package/dist/util/sliding-indicator.d.ts +24 -0
  257. package/dist/util/sliding-indicator.js +44 -0
  258. package/dist/util/temporal-field.d.ts +88 -0
  259. package/dist/util/temporal-field.js +136 -0
  260. package/dist/util/toaster.d.ts +40 -0
  261. package/dist/util/toaster.js +55 -0
  262. package/dist/util/tone-surface.d.ts +33 -0
  263. package/dist/util/tone-surface.js +35 -0
  264. package/dist/util/top-navbar.d.ts +26 -2
  265. package/dist/util/top-navbar.js +23 -0
  266. package/dist/util/zabi-strings.d.ts +9 -0
  267. package/dist/zabi-components-colors.css +9 -3
  268. package/dist/zabi-components-theme-dark-only.css +474 -164
  269. package/dist/zabi-components-theme-dark.css +474 -164
  270. package/dist/zabi-components-theme-only.css +933 -99
  271. package/dist/zabi-components-theme.css +933 -99
  272. package/dist/zabi-components.css +2275 -321
  273. package/package.json +11 -1
@@ -3,8 +3,9 @@
3
3
  import type { Snippet } from "svelte";
4
4
  import type { HTMLAttributes } from "svelte/elements";
5
5
  import ArrowLeft from "@lucide/svelte/icons/arrow-left";
6
- import { findScrollParent } from "../util/app-shell.js";
6
+ import { findScrollParent, getAppShell, isDevBuild } from "../util/app-shell.js";
7
7
  import { cn } from "../util/cn.js";
8
+ import { resolveScrolledUnder, watchScrollEdge, type ScrollEdgeMode } from "../util/scroll-edge.js";
8
9
 
9
10
  /**
10
11
  * The bar at the top of a phone screen: where you are, the way back, and
@@ -23,6 +24,16 @@
23
24
  * and the actions leave it less (a wide chip in `leading`, a very narrow
24
25
  * bar), it takes a second row of its own, as wide as the bar, and they
25
26
  * stay together on the first.
27
+ *
28
+ * The bar is flush with the page and page-coloured at rest. Once content
29
+ * has scrolled under it, it turns to frosted glass with a hairline below
30
+ * it. Inside an `AppShell` the shell says when; on a page of its own the
31
+ * bar watches the window or its nearest scrolling ancestor.
32
+ *
33
+ * With `largeTitle` the title is also drawn large on a second row under
34
+ * the bar. That row scrolls away with the content (nothing is animated),
35
+ * the 56px row stays, and its own small title fades in once the large
36
+ * one has gone under it. The heading is the large one.
26
37
  */
27
38
  type Props = Omit<HTMLAttributes<HTMLElement>, "class" | "title"> & {
28
39
  /** The name of the screen, as a heading. */
@@ -59,6 +70,11 @@
59
70
  * width; when that leaves the title under 72px the title moves to a
60
71
  * row of its own, and a `leading` wider than the row is held to the
61
72
  * row's width.
73
+ * The bar's row is on a fixed pixel scale (`--spacing: 4px` on the
74
+ * row), so the bar's own controls do not grow with the text size.
75
+ * Size what you place here on the same scale (Tailwind numeric
76
+ * classes such as `size-9`, or `calc(var(--spacing) * 9)`), not in
77
+ * rem, or it grows at 200% text while the bar's controls do not.
62
78
  */
63
79
  leading?: Snippet;
64
80
  /**
@@ -66,8 +82,54 @@
66
82
  * a 48px target; put anything more in a menu. Their size does not
67
83
  * grow with the text size here: inside the bar the spacing scale is
68
84
  * in px.
85
+ * The bar's row is on a fixed pixel scale (`--spacing: 4px` on the
86
+ * row), so the bar's own controls do not grow with the text size.
87
+ * Size what you place here on the same scale (Tailwind numeric
88
+ * classes such as `size-9`, or `calc(var(--spacing) * 9)`), not in
89
+ * rem, or it grows at 200% text while the bar's controls do not.
69
90
  */
70
91
  actions?: Snippet;
92
+ /**
93
+ * When the bar shows the glass and the hairline. `auto` follows the
94
+ * scroll position: flush at the top, glass once content is under it.
95
+ * `always` is glass all the time, `never` flush all the time.
96
+ */
97
+ scrollEdge?: ScrollEdgeMode;
98
+ /**
99
+ * Draws the title large (30px bold, two lines at most) on a second row
100
+ * under the bar. It scrolls away with the content; the bar keeps its
101
+ * 56px row, which shows the title small once the large one is gone.
102
+ * The heading is the large title; the small one is hidden from
103
+ * assistive technology. Needs `title`. `collapseOnScroll` is ignored
104
+ * with it. Before the bar has been measured (the server, no scripts)
105
+ * the whole header simply sticks and the large title stays.
106
+ */
107
+ largeTitle?: boolean;
108
+ /**
109
+ * The bar's fill. `default` is the page colour at rest. `transparent`
110
+ * has none at rest, for a canvas, an image or a colour block laid
111
+ * behind the bar, and takes the glass once content is under it.
112
+ * `brand` is the brand colour with on-brand text and controls, at rest
113
+ * and scrolled, with no glass and no hairline, so a brand block
114
+ * directly under the bar joins it. With `largeTitle` the large row is
115
+ * part of the brand block.
116
+ * `inherit` is for a bar inside a block of another colour: no fill of
117
+ * its own, no glass and no hairline at any scroll position, and the
118
+ * title, back control, actions, links, borders and focus rings take
119
+ * the colour the block gives the text. A sticky bar that has to cover
120
+ * scrolling content takes its fill from the app:
121
+ * `class="[--color-bar:var(--my-block-colour)]"`.
122
+ */
123
+ tone?: "default" | "transparent" | "brand" | "inherit";
124
+ /**
125
+ * `sticky` (default) keeps the bar at the top of what scrolls.
126
+ * `static` leaves it where it is in the page, for a bar that sits
127
+ * inside a block of the content: it scrolls away with the block, is
128
+ * never glass, and `collapseOnScroll` and the condensing of
129
+ * `largeTitle` do nothing. `class="static"`, the 8.1 way to say this,
130
+ * is read as `position="static"`.
131
+ */
132
+ position?: "sticky" | "static";
71
133
  class?: string;
72
134
  };
73
135
 
@@ -81,6 +143,10 @@
81
143
  titleLines = 1,
82
144
  leading,
83
145
  actions,
146
+ scrollEdge = "auto",
147
+ largeTitle = false,
148
+ tone = "default",
149
+ position = "sticky",
84
150
  class: className = "",
85
151
  ...restProps
86
152
  }: Props = $props();
@@ -90,15 +156,96 @@
90
156
  const backLabel = $derived(backLabelGiven ?? common().back);
91
157
 
92
158
  let host: HTMLElement | undefined = $state();
159
+
160
+ /**
161
+ * In the flow of the page, not stuck to the top. The material's layer is
162
+ * placed against the bar, so the bar is always positioned: `relative`
163
+ * here, and a `static` class from the caller is taken out and read as this.
164
+ */
165
+ const classStatic = $derived(/(?:^|\s)static(?:\s|$)/.test(className));
166
+ const inFlow = $derived(position === "static" || classStatic);
167
+ const ownClass = $derived(
168
+ classStatic ? className.replace(/(?:^|\s)static(?=\s|$)/g, " ").trim() : className,
169
+ );
170
+
171
+ /** A large title needs a title to be large. */
172
+ const large = $derived(largeTitle && !!title);
173
+ /** The two scroll behaviours fight: with a large title, `collapseOnScroll` is ignored. */
174
+ const collapses = $derived(collapseOnScroll && !large && !inFlow);
175
+
176
+ $effect(() => {
177
+ if (!isDevBuild()) return;
178
+ if (largeTitle && collapseOnScroll) {
179
+ console.warn(
180
+ "[zabi-components] AppBar: `collapseOnScroll` is ignored with `largeTitle`; " +
181
+ "the large title already condenses on scroll.",
182
+ );
183
+ }
184
+ });
185
+
186
+ /** The large row, and its measured height in px: how far the header sticks above the top. Null until measured. */
187
+ let largeRow: HTMLElement | undefined = $state();
188
+ let largeHeight = $state<number | null>(null);
189
+ const overscroll = $derived(
190
+ large && !inFlow && largeHeight !== null && largeHeight > 0 ? largeHeight : 0,
191
+ );
192
+
193
+ $effect(() => {
194
+ const el = largeRow;
195
+ if (!el || typeof ResizeObserver === "undefined") {
196
+ largeHeight = null;
197
+ return;
198
+ }
199
+ const measure = () => {
200
+ const height = el.getBoundingClientRect().height;
201
+ // No layout (a hidden bar, a test DOM): stay as unmeasured.
202
+ largeHeight = height > 0 ? height : null;
203
+ };
204
+ measure();
205
+ const observer = new ResizeObserver(measure);
206
+ observer.observe(el);
207
+ return () => observer.disconnect();
208
+ });
209
+
210
+ /** Content is under the bar: from the shell if there is one, else from its own scroller. */
211
+ const shell = getAppShell();
212
+ let ownScrolled = $state(false);
213
+ $effect(() => {
214
+ if (shell || (scrollEdge !== "auto" && !large) || !host) return;
215
+ return watchScrollEdge(findScrollParent(host), (edge) => (ownScrolled = edge.top), overscroll);
216
+ });
217
+ /** Past the large row: the standard title shows and the bar is over content. Never before it has been measured. */
218
+ const condensed = $derived(
219
+ large && overscroll > 0 && (shell ? shell.scrolledTop : ownScrolled),
220
+ );
221
+ const scrolledUnder = $derived(
222
+ // An opaque brand bar has no glass and no hairline whatever the mode says, nor has
223
+ // one that takes its block's colours (glass over any colour is not readable).
224
+ // Nor has a bar in the flow of the page: nothing scrolls under it.
225
+ tone === "brand" || tone === "inherit" || inFlow
226
+ ? false
227
+ : resolveScrolledUnder(
228
+ scrollEdge,
229
+ large ? condensed : shell ? shell.scrolledTop : ownScrolled,
230
+ ),
231
+ );
232
+
233
+ // Inside a shell the sticky container is the shell's header wrapper.
234
+ $effect(() => {
235
+ if (!shell || !(overscroll > 0)) return;
236
+ shell.setHeaderOverscroll(overscroll);
237
+ return () => shell.setHeaderOverscroll(0);
238
+ });
239
+
93
240
  /** Scrolled out of view. Only ever true with `collapseOnScroll`. */
94
241
  let scrolledAway = $state(false);
95
- const collapsed = $derived(collapseOnScroll && scrolledAway);
242
+ const collapsed = $derived(collapses && scrolledAway);
96
243
 
97
244
  /** Scrolling less than this in one direction does not move the bar. */
98
245
  const SCROLL_TOLERANCE = 8;
99
246
 
100
247
  $effect(() => {
101
- if (!collapseOnScroll || !host) return;
248
+ if (!collapses || !host) return;
102
249
  const bar = host;
103
250
  const parent = findScrollParent(bar);
104
251
  const target: HTMLElement | Window = parent ?? window;
@@ -187,8 +334,13 @@
187
334
  });
188
335
 
189
336
  /** A 48px ghost target, the same as `IconButton` at `variant="ghost" size="lg"`. */
190
- const backClasses =
191
- "focus-ring focus-ring--muted inline-flex size-12 shrink-0 cursor-pointer items-center justify-center rounded-control bg-transparent text-headline transition-colors duration-150 hover:bg-surface-hover active:bg-surface-active";
337
+ const backClasses = $derived(
338
+ "focus-ring focus-ring--muted inline-flex size-12 shrink-0 cursor-pointer items-center justify-center rounded-control bg-transparent text-headline transition-colors duration-(--duration-base) " +
339
+ // The page's hover fills are a dark veil: on the brand fill the on-brand colour is what shows.
340
+ (tone === "brand" || tone === "inherit"
341
+ ? "hover:bg-[color-mix(in_srgb,var(--color-on-brand)_16%,transparent)] active:bg-[color-mix(in_srgb,var(--color-on-brand)_26%,transparent)]"
342
+ : "hover:bg-surface-hover active:bg-surface-active"),
343
+ );
192
344
 
193
345
  const hasBack = $derived(backHref !== undefined || onback !== undefined);
194
346
 
@@ -211,6 +363,35 @@
211
363
  let leadingMax = $state<number | null>(null);
212
364
  let row: HTMLElement | undefined = $state();
213
365
 
366
+ /** 1.875rem, and no more than 39px however large the text is set: capped as the bar's other sizes are. */
367
+ const largeClasses =
368
+ "appbar-large-title m-0 text-[length:min(1.875rem,39px)] leading-[1.15] font-bold text-headline line-clamp-2 [overflow-wrap:anywhere]";
369
+
370
+ const headerClasses = $derived(
371
+ cn(
372
+ cn(!large && "material-bar", inFlow ? "relative" : "sticky top-0 z-sticky"),
373
+ // Clear of the status bar and the notch, and of the corners in landscape. With a
374
+ // large title the status-bar strip is the 56px row's, so it stays covered when stuck.
375
+ large
376
+ ? "pl-[env(safe-area-inset-left)] pr-[env(safe-area-inset-right)]"
377
+ : "pt-[env(safe-area-inset-top)] pl-[env(safe-area-inset-left)] pr-[env(safe-area-inset-right)]",
378
+ !large && "transition-transform duration-(--duration-moderate) ease-out motion-reduce:transition-none",
379
+ // The strip under the status bar stays, so content never shows through it.
380
+ !large && collapsed && "-translate-y-[calc(100%-env(safe-area-inset-top,0px))]",
381
+ // `--color-bar` is scoped on the element that carries `material-bar`.
382
+ !large && tone === "transparent" && "[--color-bar:transparent]",
383
+ !large && tone === "brand" && "[--color-bar:var(--color-bar-brand)]",
384
+ // With a large title the inner bar takes whatever the header says (see below), so a caller's fill reaches it.
385
+ tone === "inherit" && "[--color-bar:transparent]",
386
+ tone === "brand" && "on-brand",
387
+ // The on-brand scope with the on-colour left as the block's own text colour; the
388
+ // focus ring's gap is the block's, not the brand fill.
389
+ tone === "inherit" && "on-brand [--color-on-brand:currentColor] [--color-focus-ring-offset:transparent]",
390
+ // The caller's classes come last: their `[--color-bar:…]` replaces the tone's.
391
+ ownClass,
392
+ ),
393
+ );
394
+
214
395
  $effect(() => {
215
396
  const bar = row;
216
397
  if (!bar || typeof ResizeObserver === "undefined") return;
@@ -218,10 +399,18 @@
218
399
  void title;
219
400
  void titleLines;
220
401
 
402
+ // `offsetWidth` is rounded: actions 62.016px wide read as 62, the row came out
403
+ // 0.016px too wide for what was left and wrapped. The laid-out width, up to the
404
+ // next pixel (layout units are 1/64px, so the tiny allowance is only float noise).
405
+ const widthOf = (element: HTMLElement) => Math.ceil(element.getBoundingClientRect().width - 0.001);
406
+
221
407
  const measure = () => {
222
408
  const style = getComputedStyle(bar);
409
+ // Down to the whole pixel, as the parts are counted up to it: `clientWidth` rounds either way.
223
410
  const inner =
224
- bar.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
411
+ Math.floor(bar.getBoundingClientRect().width + 0.001) -
412
+ parseFloat(style.paddingLeft) -
413
+ parseFloat(style.paddingRight);
225
414
  // Nothing is laid out (a hidden bar): leave it as it is.
226
415
  if (!(inner > 0)) return;
227
416
  const gap = parseFloat(style.columnGap) || 0;
@@ -235,16 +424,18 @@
235
424
  for (const child of bar.children) {
236
425
  if (!(child instanceof HTMLElement)) continue;
237
426
  const part = child.dataset.appbarPart;
238
- if (part === "title" || child.offsetWidth === 0) continue;
427
+ const width = widthOf(child);
428
+ if (part === "title" || width <= 0) continue;
239
429
  parts += 1;
240
- beside += child.offsetWidth;
430
+ beside += width;
241
431
  // The back control and the actions keep their width whatever happens.
242
- if (part === "back" || part === "actions") fixed += child.offsetWidth;
432
+ if (part === "back" || part === "actions") fixed += width;
243
433
  }
244
434
  delete bar.dataset.measuring;
245
435
 
246
436
  const left = inner - beside - gap * parts;
247
- const next = left < TITLE_MIN ? "own" : "inline";
437
+ // A bar with no title has nothing to put on a row of its own.
438
+ const next = title && left < TITLE_MIN ? "own" : "inline";
248
439
  if (next !== titleRow) titleRow = next;
249
440
  const fixedParts = bar.querySelectorAll(
250
441
  ":scope > [data-appbar-part='back'], :scope > [data-appbar-part='actions']",
@@ -274,20 +465,7 @@
274
465
  });
275
466
  </script>
276
467
 
277
- <header
278
- bind:this={host}
279
- data-collapsed={collapseOnScroll ? String(collapsed) : undefined}
280
- class={cn(
281
- "sticky top-0 z-sticky border-b border-border-weak bg-surface-elevated",
282
- // Clear of the status bar and the notch, and of the corners in landscape.
283
- "pt-[env(safe-area-inset-top)] pl-[env(safe-area-inset-left)] pr-[env(safe-area-inset-right)]",
284
- "transition-transform duration-200 ease-out motion-reduce:transition-none",
285
- // The strip under the status bar stays, so content never shows through it.
286
- collapsed && "-translate-y-[calc(100%-env(safe-area-inset-top,0px))]",
287
- className,
288
- )}
289
- {...restProps}
290
- >
468
+ {#snippet barRow()}
291
469
  <!-- `--spacing: 4px`: Tailwind sizes everything from `--spacing`, which
292
470
  is 0.25rem, so a 48px button was 96px with the text at 200% and the bar
293
471
  wrapped to 225px. In px here, the back control, the actions and whatever
@@ -300,10 +478,11 @@
300
478
  <div
301
479
  bind:this={row}
302
480
  data-title-row={titleRow ?? undefined}
481
+ data-title-empty={title ? undefined : ""}
303
482
  style:--appbar-leading-max={leadingMax !== null ? `${leadingMax}px` : undefined}
304
483
  class={cn(
305
484
  "appbar-row flex min-h-14 items-center gap-2 px-2 py-1 [--spacing:4px]",
306
- "transition-opacity duration-200 ease-out motion-reduce:transition-none",
485
+ "transition-opacity duration-(--duration-moderate) ease-out motion-reduce:transition-none",
307
486
  collapsed && "pointer-events-none opacity-0",
308
487
  )}
309
488
  >
@@ -322,14 +501,22 @@
322
501
  `titleLines={2}`. 1.125rem, and no more than 1.3 times that
323
502
  (23.4px) however large the text is set.
324
503
  One branch per level, not a dynamic element: hydration takes a dynamic
325
- element out and puts it back, which blurs a control inside it that the user
326
- had already tabbed to. -->
504
+ element out and puts it back, which blurs a control inside it that the
505
+ user had already tabbed to. -->
327
506
  {@const titleClasses = cn(
328
507
  "appbar-title m-0 text-[length:min(1.125rem,23.4px)] leading-[1.34] font-semibold text-headline",
329
508
  titleLines === 2 ? "line-clamp-2 [overflow-wrap:anywhere]" : "truncate",
330
509
  !hasBack && !leading && "ps-2",
331
510
  )}
332
- {#if headingLevel === 1}
511
+ {#if large}
512
+ <!-- The large row holds the heading. This one only repeats the text for the
513
+ eye, in the title's place so the row measures as before. -->
514
+ <p
515
+ class={cn(titleClasses, "transition-opacity duration-(--duration-base)", condensed ? "opacity-100" : "opacity-0")}
516
+ data-appbar-part="title"
517
+ aria-hidden="true"
518
+ >{title}</p>
519
+ {:else if headingLevel === 1}
333
520
  <h1 class={titleClasses} data-appbar-part="title">{title}</h1>
334
521
  {:else if headingLevel === 2}
335
522
  <h2 class={titleClasses} data-appbar-part="title">{title}</h2>
@@ -343,6 +530,7 @@
343
530
  <h6 class={titleClasses} data-appbar-part="title">{title}</h6>
344
531
  {/if}
345
532
  {:else}
533
+ <!-- Nothing to show: no width, so no 72px to find room for. -->
346
534
  <div class="min-w-0 flex-1" data-appbar-part="title"></div>
347
535
  {/if}
348
536
  {#if actions}
@@ -351,6 +539,61 @@
351
539
  </div>
352
540
  {/if}
353
541
  </div>
542
+ {/snippet}
543
+
544
+ <header
545
+ bind:this={host}
546
+ data-appbar=""
547
+ data-tone={tone}
548
+ data-large-title={large ? "" : undefined}
549
+ data-condensed={large ? String(condensed) : undefined}
550
+ data-collapsed={collapses ? String(collapsed) : undefined}
551
+ data-scrolled-under={large ? undefined : String(scrolledUnder)}
552
+ style:top={large && !shell && overscroll > 0 ? `-${overscroll}px` : undefined}
553
+ class={headerClasses}
554
+ {...restProps}
555
+ >
556
+ {#if large}
557
+ <!-- Two rows. The header sticks at minus the large row's height and the
558
+ 56px row sticks at 0 inside it, so the large row scrolls away behind a bar
559
+ that stays: no scroll-linked animation, no jump. The surface and the
560
+ status-bar strip belong to the 56px row; the large row is on the page
561
+ (on the brand fill, with `tone="brand"`). -->
562
+ <div
563
+ data-appbar-bar
564
+ data-scrolled-under={String(scrolledUnder)}
565
+ class={cn(
566
+ "material-bar pt-[env(safe-area-inset-top)]",
567
+ inFlow ? "relative" : "sticky top-0",
568
+ tone === "transparent" && "[--color-bar:transparent]",
569
+ tone === "brand" && "[--color-bar:var(--color-bar-brand)]",
570
+ tone === "inherit" && "[--color-bar:inherit]",
571
+ )}
572
+ >
573
+ {@render barRow()}
574
+ </div>
575
+ <div
576
+ bind:this={largeRow}
577
+ data-appbar-large
578
+ class={cn("px-[16px] pt-[4px] pb-[8px]", tone === "brand" && "bg-bar-brand")}
579
+ >
580
+ {#if headingLevel === 1}
581
+ <h1 class={largeClasses}>{title}</h1>
582
+ {:else if headingLevel === 2}
583
+ <h2 class={largeClasses}>{title}</h2>
584
+ {:else if headingLevel === 3}
585
+ <h3 class={largeClasses}>{title}</h3>
586
+ {:else if headingLevel === 4}
587
+ <h4 class={largeClasses}>{title}</h4>
588
+ {:else if headingLevel === 5}
589
+ <h5 class={largeClasses}>{title}</h5>
590
+ {:else}
591
+ <h6 class={largeClasses}>{title}</h6>
592
+ {/if}
593
+ </div>
594
+ {:else}
595
+ {@render barRow()}
596
+ {/if}
354
597
  </header>
355
598
 
356
599
  <style>
@@ -404,6 +647,22 @@
404
647
  padding-block-end: 4px;
405
648
  }
406
649
 
650
+ /*
651
+ * No title: the empty part asks for no width and no row, on the server
652
+ * and before measuring as well. One row whatever `leading` holds, which
653
+ * is cut to the room beside the back control and the actions. The part
654
+ * pulls back one gap, so the row spends none on it.
655
+ */
656
+ .appbar-row[data-title-empty] {
657
+ flex-wrap: nowrap;
658
+ }
659
+ .appbar-row[data-title-empty] :global(> [data-appbar-part="title"]) {
660
+ flex: 0 0 0;
661
+ min-width: 0;
662
+ padding: 0;
663
+ margin-inline-end: -8px;
664
+ }
665
+
407
666
  /* While it measures: every part at the width it asks for, on one line. */
408
667
  .appbar-row[data-measuring] {
409
668
  flex-wrap: nowrap;
@@ -1,5 +1,6 @@
1
1
  import type { Snippet } from "svelte";
2
2
  import type { HTMLAttributes } from "svelte/elements";
3
+ import { type ScrollEdgeMode } from "../util/scroll-edge.js";
3
4
  /**
4
5
  * The bar at the top of a phone screen: where you are, the way back, and
5
6
  * at most two things to do here.
@@ -17,6 +18,16 @@ import type { HTMLAttributes } from "svelte/elements";
17
18
  * and the actions leave it less (a wide chip in `leading`, a very narrow
18
19
  * bar), it takes a second row of its own, as wide as the bar, and they
19
20
  * stay together on the first.
21
+ *
22
+ * The bar is flush with the page and page-coloured at rest. Once content
23
+ * has scrolled under it, it turns to frosted glass with a hairline below
24
+ * it. Inside an `AppShell` the shell says when; on a page of its own the
25
+ * bar watches the window or its nearest scrolling ancestor.
26
+ *
27
+ * With `largeTitle` the title is also drawn large on a second row under
28
+ * the bar. That row scrolls away with the content (nothing is animated),
29
+ * the 56px row stays, and its own small title fades in once the large
30
+ * one has gone under it. The heading is the large one.
20
31
  */
21
32
  type Props = Omit<HTMLAttributes<HTMLElement>, "class" | "title"> & {
22
33
  /** The name of the screen, as a heading. */
@@ -53,6 +64,11 @@ type Props = Omit<HTMLAttributes<HTMLElement>, "class" | "title"> & {
53
64
  * width; when that leaves the title under 72px the title moves to a
54
65
  * row of its own, and a `leading` wider than the row is held to the
55
66
  * row's width.
67
+ * The bar's row is on a fixed pixel scale (`--spacing: 4px` on the
68
+ * row), so the bar's own controls do not grow with the text size.
69
+ * Size what you place here on the same scale (Tailwind numeric
70
+ * classes such as `size-9`, or `calc(var(--spacing) * 9)`), not in
71
+ * rem, or it grows at 200% text while the bar's controls do not.
56
72
  */
57
73
  leading?: Snippet;
58
74
  /**
@@ -60,8 +76,54 @@ type Props = Omit<HTMLAttributes<HTMLElement>, "class" | "title"> & {
60
76
  * a 48px target; put anything more in a menu. Their size does not
61
77
  * grow with the text size here: inside the bar the spacing scale is
62
78
  * in px.
79
+ * The bar's row is on a fixed pixel scale (`--spacing: 4px` on the
80
+ * row), so the bar's own controls do not grow with the text size.
81
+ * Size what you place here on the same scale (Tailwind numeric
82
+ * classes such as `size-9`, or `calc(var(--spacing) * 9)`), not in
83
+ * rem, or it grows at 200% text while the bar's controls do not.
63
84
  */
64
85
  actions?: Snippet;
86
+ /**
87
+ * When the bar shows the glass and the hairline. `auto` follows the
88
+ * scroll position: flush at the top, glass once content is under it.
89
+ * `always` is glass all the time, `never` flush all the time.
90
+ */
91
+ scrollEdge?: ScrollEdgeMode;
92
+ /**
93
+ * Draws the title large (30px bold, two lines at most) on a second row
94
+ * under the bar. It scrolls away with the content; the bar keeps its
95
+ * 56px row, which shows the title small once the large one is gone.
96
+ * The heading is the large title; the small one is hidden from
97
+ * assistive technology. Needs `title`. `collapseOnScroll` is ignored
98
+ * with it. Before the bar has been measured (the server, no scripts)
99
+ * the whole header simply sticks and the large title stays.
100
+ */
101
+ largeTitle?: boolean;
102
+ /**
103
+ * The bar's fill. `default` is the page colour at rest. `transparent`
104
+ * has none at rest, for a canvas, an image or a colour block laid
105
+ * behind the bar, and takes the glass once content is under it.
106
+ * `brand` is the brand colour with on-brand text and controls, at rest
107
+ * and scrolled, with no glass and no hairline, so a brand block
108
+ * directly under the bar joins it. With `largeTitle` the large row is
109
+ * part of the brand block.
110
+ * `inherit` is for a bar inside a block of another colour: no fill of
111
+ * its own, no glass and no hairline at any scroll position, and the
112
+ * title, back control, actions, links, borders and focus rings take
113
+ * the colour the block gives the text. A sticky bar that has to cover
114
+ * scrolling content takes its fill from the app:
115
+ * `class="[--color-bar:var(--my-block-colour)]"`.
116
+ */
117
+ tone?: "default" | "transparent" | "brand" | "inherit";
118
+ /**
119
+ * `sticky` (default) keeps the bar at the top of what scrolls.
120
+ * `static` leaves it where it is in the page, for a bar that sits
121
+ * inside a block of the content: it scrolls away with the block, is
122
+ * never glass, and `collapseOnScroll` and the condensing of
123
+ * `largeTitle` do nothing. `class="static"`, the 8.1 way to say this,
124
+ * is read as `position="static"`.
125
+ */
126
+ position?: "sticky" | "static";
65
127
  class?: string;
66
128
  };
67
129
  declare const AppBar: import("svelte").Component<Props, {}, "">;
@@ -93,7 +93,7 @@
93
93
  >
94
94
  {#each shown as person, index (index)}
95
95
  <li class={cn("flex", index > 0 && (OVERLAP[size] ?? OVERLAP.md))}>
96
- <Avatar name={person.name} src={person.src} {size} {locale} class={ring} />
96
+ <Avatar name={person.name} src={person.src} children={person.picture} {size} {locale} class={ring} />
97
97
  </li>
98
98
  {/each}
99
99
  {#if split.hidden > 0}