@c9up/nebula 0.1.5 → 0.1.7

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 (42) hide show
  1. package/dist/adapters/tailwind.d.ts +7 -0
  2. package/dist/adapters/tailwind.js +11 -1
  3. package/dist/adapters/unocss.js +11 -2
  4. package/dist/atoms/Image.d.ts +106 -0
  5. package/dist/atoms/Image.js +139 -0
  6. package/dist/atoms/NativeSelect.js +27 -2
  7. package/dist/atoms/index.d.ts +1 -0
  8. package/dist/atoms/index.js +1 -0
  9. package/dist/lib/image.d.ts +213 -0
  10. package/dist/lib/image.js +331 -0
  11. package/dist/lib/index.d.ts +1 -0
  12. package/dist/lib/index.js +1 -0
  13. package/dist/lib/motion.d.ts +25 -16
  14. package/dist/lib/motion.js +30 -24
  15. package/dist/molecules/Picture.d.ts +46 -0
  16. package/dist/molecules/Picture.js +62 -0
  17. package/dist/molecules/index.d.ts +1 -0
  18. package/dist/molecules/index.js +1 -0
  19. package/dist/organisms/Dialog.d.ts +11 -4
  20. package/dist/organisms/Dialog.js +2 -2
  21. package/dist/organisms/Sidebar.d.ts +9 -0
  22. package/dist/organisms/Sidebar.js +5 -2
  23. package/dist/primitives/floatingSurface.js +17 -8
  24. package/dist/primitives/presence.js +401 -5
  25. package/nebula.css +1 -1
  26. package/package.json +7 -6
  27. package/registry.json +25 -0
  28. package/src/adapters/tailwind.ts +11 -1
  29. package/src/adapters/unocss.ts +11 -2
  30. package/src/atoms/Image.ts +226 -0
  31. package/src/atoms/NativeSelect.ts +27 -2
  32. package/src/atoms/index.ts +10 -0
  33. package/src/lib/image.ts +470 -0
  34. package/src/lib/index.ts +25 -0
  35. package/src/lib/motion.ts +30 -26
  36. package/src/molecules/Picture.ts +92 -0
  37. package/src/molecules/index.ts +1 -0
  38. package/src/organisms/Dialog.ts +13 -6
  39. package/src/organisms/Sidebar.ts +15 -2
  40. package/src/primitives/floatingSurface.ts +18 -9
  41. package/src/primitives/presence.ts +428 -5
  42. package/theme.css +0 -35
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Image — one `<img>`, with the attributes that make it fast already filled in.
3
+ *
4
+ * A bare `<img src>` costs a page twice: it ships desktop pixels to a phone,
5
+ * and it reserves no space until it arrives, so everything under it jumps.
6
+ * Both are solved by attributes nobody writes by hand — a `srcset` of a dozen
7
+ * widths, a matching `sizes`, `width`/`height` for the ratio, `loading` and
8
+ * `decoding`. This component writes them.
9
+ *
10
+ * The bytes come from whatever {@link setImageResolver} is pointed at; the
11
+ * component only decides which variants to ask for. See `lib/image.ts` for
12
+ * why the split is there and what the resolver is allowed to be asked.
13
+ *
14
+ * ```ts
15
+ * Image({ src: "/photos/hero.jpg", alt: "…", width: 1200, height: 800 })
16
+ * Image({ src: "/logo.png", alt: "…", width: 48, height: 48, layout: "fixed" })
17
+ * Image({ src: "/banner.jpg", alt: "…", layout: "full-width", priority: true })
18
+ * ```
19
+ *
20
+ * `alt` is required, and not as a gesture: the attribute is what a screen
21
+ * reader announces, and an `<img>` without one is read out as its file name.
22
+ * Decorative images pass `alt: ""`, which is the markup that says "skip me"
23
+ * — a deliberate empty string, not a forgotten prop.
24
+ */
25
+
26
+ import { component, html } from "@c9up/aurora";
27
+ import { cn } from "../lib/cn.js";
28
+ import {
29
+ densitySrcSet,
30
+ fitClass,
31
+ type ImageFit,
32
+ type ImageFormat,
33
+ type ImageLayout,
34
+ type ImagePosition,
35
+ imageSizes,
36
+ imageSrcSet,
37
+ imageUrl,
38
+ imageWidths,
39
+ layoutClasses,
40
+ offeredWidth,
41
+ positionClass,
42
+ } from "../lib/image.js";
43
+ import { type Reactive, read, readOr } from "../lib/props.js";
44
+
45
+ export interface ImageProps {
46
+ src: Reactive<string>;
47
+ /** Required. `""` for a decorative image, which is what hides it. */
48
+ alt: Reactive<string>;
49
+ /**
50
+ * Displayed width in pixels, and the `width` attribute.
51
+ *
52
+ * Together with `height` it is what reserves the space before the bytes
53
+ * arrive. Optional only because `full-width` does not need it.
54
+ */
55
+ width?: Reactive<number | undefined>;
56
+ height?: Reactive<number | undefined>;
57
+ /** Defaults to `constrained`. */
58
+ layout?: Reactive<ImageLayout>;
59
+ /** CSS `object-fit`. The image is never cropped server-side. */
60
+ fit?: Reactive<ImageFit | undefined>;
61
+ /** CSS `object-position`, for when `fit` crops. */
62
+ position?: Reactive<ImagePosition | undefined>;
63
+ /** Output format to request. Defaults to `webp`. */
64
+ format?: Reactive<ImageFormat | undefined>;
65
+ /** 1-100. The endpoint decides which values it accepts. */
66
+ quality?: Reactive<number | undefined>;
67
+ /** Override the generated width ladder. Pair it with `sizes`. */
68
+ widths?: Reactive<readonly number[] | undefined>;
69
+ /**
70
+ * Serve by pixel ratio instead of width, e.g. `[1, 2]`.
71
+ *
72
+ * Mutually exclusive with `widths` — a `srcset` mixing `w` and `x`
73
+ * descriptors is invalid, and a browser that sees one ignores the
74
+ * attribute entirely. `widths` wins if both are given.
75
+ */
76
+ densities?: Reactive<readonly number[] | undefined>;
77
+ /** Override the generated `sizes`. */
78
+ sizes?: Reactive<string | undefined>;
79
+ /** Width ladder to draw from. Defaults to the device-width ladder. */
80
+ breakpoints?: Reactive<readonly number[] | undefined>;
81
+ /** The source's own width, when known. Stops variants past it being offered. */
82
+ originalWidth?: Reactive<number | undefined>;
83
+ /**
84
+ * Above the fold: load it now.
85
+ *
86
+ * Sets `loading="eager"`, `decoding="sync"` and `fetchpriority="high"`
87
+ * together, because setting one without the others is the usual reason a
88
+ * hero image is still not the first thing painted.
89
+ */
90
+ priority?: Reactive<boolean>;
91
+ loading?: Reactive<"lazy" | "eager" | undefined>;
92
+ decoding?: Reactive<"async" | "sync" | "auto" | undefined>;
93
+ fetchPriority?: Reactive<"high" | "low" | "auto" | undefined>;
94
+ class?: Reactive<string>;
95
+ }
96
+
97
+ /**
98
+ * The widths this image offers, honouring an explicit `widths` prop.
99
+ *
100
+ * Exported because `Picture` needs exactly the same ladder for every one of
101
+ * its `<source>` elements — computing it twice is how the AVIF and WebP
102
+ * variants end up offering different sizes.
103
+ */
104
+ export function resolvedWidths(props: ImageProps): number[] {
105
+ const explicit = read(props.widths);
106
+ if (explicit !== undefined) return [...explicit];
107
+ return imageWidths({
108
+ width: read(props.width),
109
+ layout: readOr(props.layout, "constrained"),
110
+ breakpoints: read(props.breakpoints),
111
+ originalWidth: read(props.originalWidth),
112
+ });
113
+ }
114
+
115
+ /** The `srcset` for one format, in whichever descriptor style applies. */
116
+ export function resolvedSrcSet(
117
+ props: ImageProps,
118
+ format: ImageFormat | undefined,
119
+ ): string | undefined {
120
+ const quality = read(props.quality);
121
+ const width = read(props.width);
122
+ const densities = read(props.densities);
123
+
124
+ // `widths` wins: an explicit ladder is the more specific instruction, and
125
+ // honouring both would emit the invalid mixed-descriptor set.
126
+ if (densities !== undefined && read(props.widths) === undefined) {
127
+ if (width === undefined) return undefined;
128
+ return densitySrcSet({
129
+ src: read(props.src),
130
+ width,
131
+ densities,
132
+ format,
133
+ quality,
134
+ originalWidth: read(props.originalWidth),
135
+ breakpoints: read(props.breakpoints),
136
+ });
137
+ }
138
+ return imageSrcSet({
139
+ src: read(props.src),
140
+ widths: resolvedWidths(props),
141
+ format,
142
+ quality,
143
+ });
144
+ }
145
+
146
+ /** The `sizes` for this image, or `undefined` when it carries `x` descriptors. */
147
+ export function resolvedSizes(props: ImageProps): string | undefined {
148
+ const explicit = read(props.sizes);
149
+ if (explicit !== undefined) return explicit;
150
+ // Density srcsets describe themselves; a `sizes` alongside them means
151
+ // nothing and browsers ignore it.
152
+ if (read(props.densities) !== undefined && read(props.widths) === undefined) {
153
+ return undefined;
154
+ }
155
+ return imageSizes({
156
+ width: read(props.width),
157
+ layout: readOr(props.layout, "constrained"),
158
+ });
159
+ }
160
+
161
+ /**
162
+ * The `src` a browser falls back to.
163
+ *
164
+ * The declared width rather than the largest variant: it is what a browser
165
+ * with no `srcset` support (and every crawler that reads the attribute
166
+ * literally) will fetch.
167
+ */
168
+ export function resolvedSrc(
169
+ props: ImageProps,
170
+ format: ImageFormat | undefined,
171
+ ): string {
172
+ const src = read(props.src);
173
+ const width = read(props.width);
174
+ if (width === undefined) return src;
175
+ return imageUrl(src, {
176
+ // Snapped like every other entry: the declared width is almost never a
177
+ // width the endpoint offers, and this URL is the one a browser without
178
+ // `srcset` support fetches.
179
+ width: offeredWidth(width, read(props.breakpoints)),
180
+ format,
181
+ quality: read(props.quality),
182
+ });
183
+ }
184
+
185
+ /** The class list for the layout, fit and position of an image. */
186
+ export function imageClasses(props: ImageProps): string {
187
+ return cn(
188
+ layoutClasses(readOr(props.layout, "constrained")),
189
+ fitClass(read(props.fit)),
190
+ positionClass(read(props.position)),
191
+ read(props.class),
192
+ );
193
+ }
194
+
195
+ /** `loading`, `decoding` and `fetchpriority`, with `priority` applied. */
196
+ export function loadingAttributes(props: ImageProps): {
197
+ loading: "lazy" | "eager";
198
+ decoding: "async" | "sync" | "auto";
199
+ fetchPriority: "high" | "low" | "auto" | undefined;
200
+ } {
201
+ const priority = readOr(props.priority, false);
202
+ return {
203
+ loading: read(props.loading) ?? (priority ? "eager" : "lazy"),
204
+ decoding: read(props.decoding) ?? (priority ? "sync" : "async"),
205
+ fetchPriority: read(props.fetchPriority) ?? (priority ? "high" : undefined),
206
+ };
207
+ }
208
+
209
+ export const Image = component<ImageProps>((props) => {
210
+ const format = (): ImageFormat | undefined => read(props.format) ?? "webp";
211
+
212
+ return html`<img
213
+ data-slot="image"
214
+ data-layout="${() => readOr(props.layout, "constrained")}"
215
+ src="${() => resolvedSrc(props, format())}"
216
+ srcset="${() => resolvedSrcSet(props, format())}"
217
+ sizes="${() => resolvedSizes(props)}"
218
+ alt="${() => read(props.alt)}"
219
+ width="${() => read(props.width)}"
220
+ height="${() => read(props.height)}"
221
+ loading="${() => loadingAttributes(props).loading}"
222
+ decoding="${() => loadingAttributes(props).decoding}"
223
+ fetchpriority="${() => loadingAttributes(props).fetchPriority}"
224
+ class="${() => imageClasses(props)}"
225
+ />`;
226
+ });
@@ -72,8 +72,26 @@ export const NativeSelect = component<NativeSelectProps>((props) => {
72
72
  return out;
73
73
  }
74
74
 
75
+ /**
76
+ * `?selected` on each option, NOT `.value` on the select alone.
77
+ *
78
+ * A property binding sits on the opening tag, so it is assigned before the
79
+ * options exist. `select.value = 'x'` against a childless `<select>` is a
80
+ * no-op the DOM does not report, and nothing re-runs it afterwards: the
81
+ * control then shows its FIRST option while holding the value it was given.
82
+ * Nothing throws and nothing logs, so the value the user sees is not the
83
+ * value the form will submit — in a form that edits money or tax status
84
+ * that is a wrong record written on save.
85
+ *
86
+ * Marking the option is order-independent, and it is also what makes the
87
+ * choice correct in server-rendered HTML before any script runs.
88
+ */
75
89
  const renderOption = (option: NativeSelectOption) =>
76
- html`<option value="${option.value}" ?disabled="${option.disabled === true}">
90
+ html`<option
91
+ value="${option.value}"
92
+ ?disabled="${option.disabled === true}"
93
+ ?selected="${() => read(props.value) === option.value}"
94
+ >
77
95
  ${option.label}
78
96
  </option>`;
79
97
 
@@ -92,7 +110,14 @@ export const NativeSelect = component<NativeSelectProps>((props) => {
92
110
  ${
93
111
  props.placeholder === undefined
94
112
  ? null
95
- : html`<option value="" disabled selected>${props.placeholder}</option>`
113
+ : html`<option
114
+ value=""
115
+ disabled
116
+ ?selected="${() => {
117
+ const current = read(props.value);
118
+ return current === undefined || current === "";
119
+ }}"
120
+ >${props.placeholder}</option>`
96
121
  }
97
122
  ${runs().map((run) =>
98
123
  run.group === undefined
@@ -27,6 +27,16 @@ export {
27
27
  buttonVariants,
28
28
  } from "./Button.js";
29
29
  export { Checkbox, type CheckboxProps, checkboxClasses } from "./Checkbox.js";
30
+ export {
31
+ Image,
32
+ type ImageProps,
33
+ imageClasses,
34
+ loadingAttributes,
35
+ resolvedSizes,
36
+ resolvedSrc,
37
+ resolvedSrcSet,
38
+ resolvedWidths,
39
+ } from "./Image.js";
30
40
  export { Input, type InputProps, inputClasses } from "./Input.js";
31
41
  export { Kbd, type KbdProps } from "./Kbd.js";
32
42
  export { Label, type LabelProps, labelClasses } from "./Label.js";