@c9up/nebula 0.1.5 → 0.1.6

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.
@@ -14,6 +14,13 @@
14
14
  * a v4 setup produces an unstyled page.
15
15
  * - `@custom-variant dark` makes `dark:` respond to a `.dark` class rather
16
16
  * than the system preference, so a theme toggle can override the OS.
17
+ * - `tw-animate-css` supplies `animate-in` / `animate-out` and the `fade-*`,
18
+ * `zoom-*` and `slide-*` modifiers — where shadcn gets them too. Imported
19
+ * rather than reimplemented, so the components use its class strings
20
+ * verbatim. nebula previously declared four keyframes of its own and reached
21
+ * them through arbitrary animation values, which Tailwind compiles whether or
22
+ * not the keyframes exist: an app that skipped the stylesheet got overlays
23
+ * that never finished CLOSING rather than overlays that never animated.
17
24
  * - `@theme inline` maps nebula's plain custom properties onto Tailwind's
18
25
  * colour namespace, which is what turns `--primary` into a `bg-primary`
19
26
  * utility.
@@ -14,6 +14,13 @@
14
14
  * a v4 setup produces an unstyled page.
15
15
  * - `@custom-variant dark` makes `dark:` respond to a `.dark` class rather
16
16
  * than the system preference, so a theme toggle can override the OS.
17
+ * - `tw-animate-css` supplies `animate-in` / `animate-out` and the `fade-*`,
18
+ * `zoom-*` and `slide-*` modifiers — where shadcn gets them too. Imported
19
+ * rather than reimplemented, so the components use its class strings
20
+ * verbatim. nebula previously declared four keyframes of its own and reached
21
+ * them through arbitrary animation values, which Tailwind compiles whether or
22
+ * not the keyframes exist: an app that skipped the stylesheet got overlays
23
+ * that never finished CLOSING rather than overlays that never animated.
17
24
  * - `@theme inline` maps nebula's plain custom properties onto Tailwind's
18
25
  * colour namespace, which is what turns `--primary` into a `bg-primary`
19
26
  * utility.
@@ -123,7 +130,10 @@ function relativeFromCss(config) {
123
130
  export const tailwindAdapter = {
124
131
  name: "tailwind",
125
132
  summary: "Tailwind CSS v4, configured in CSS. What shadcn/ui itself targets.",
126
- packages: ["tailwindcss", "@tailwindcss/cli"],
133
+ // `tw-animate-css` is where the overlay animations come from — the same
134
+ // package shadcn tells you to install, and the reason the components can use
135
+ // its class strings verbatim.
136
+ packages: ["tailwindcss", "@tailwindcss/cli", "tw-animate-css"],
127
137
  files(config) {
128
138
  return [
129
139
  {
@@ -16,6 +16,7 @@
16
16
  const CONFIG_PATH = "uno.config.ts";
17
17
  function unoConfig(config) {
18
18
  return `import { defineConfig, presetWind4 } from 'unocss'
19
+ import presetAnimations from 'unocss-preset-animations'
19
20
  import { readFileSync } from 'node:fs'
20
21
  import { createRequire } from 'node:module'
21
22
 
@@ -40,7 +41,15 @@ const tokens = readFileSync(require.resolve('@c9up/nebula/theme.css'), 'utf8')
40
41
  const color = (name: string) => \`var(--\${name})\`
41
42
 
42
43
  export default defineConfig({
43
- presets: [presetWind4()],
44
+ /*
45
+ * \`presetAnimations\` is the UnoCSS port of the same utilities shadcn uses
46
+ * through \`tw-animate-css\`: \`animate-in\` / \`animate-out\` and the
47
+ * \`fade-*\` / \`zoom-*\` / \`slide-*\` modifiers. nebula's components emit
48
+ * shadcn's class strings verbatim, so the utilities have to exist under every
49
+ * engine — and registering them is what makes a missing setup produce no
50
+ * animation rather than an overlay that never finishes closing.
51
+ */
52
+ presets: [presetWind4(), presetAnimations()],
44
53
  content: {
45
54
  filesystem: ['${config.paths.components}/**/*.{ts,js}'],
46
55
  },
@@ -110,7 +119,7 @@ body {
110
119
  export const unocssAdapter = {
111
120
  name: "unocss",
112
121
  summary: "UnoCSS with presetWind4 — same class syntax, no PostCSS, faster builds.",
113
- packages: ["unocss", "@unocss/cli"],
122
+ packages: ["unocss", "@unocss/cli", "unocss-preset-animations"],
114
123
  files(config) {
115
124
  return [
116
125
  { path: CONFIG_PATH, contents: unoConfig(config), skipIfExists: true },
@@ -44,7 +44,25 @@ export const NativeSelect = component((props) => {
44
44
  }
45
45
  return out;
46
46
  }
47
- const renderOption = (option) => html `<option value="${option.value}" ?disabled="${option.disabled === true}">
47
+ /**
48
+ * `?selected` on each option, NOT `.value` on the select alone.
49
+ *
50
+ * A property binding sits on the opening tag, so it is assigned before the
51
+ * options exist. `select.value = 'x'` against a childless `<select>` is a
52
+ * no-op the DOM does not report, and nothing re-runs it afterwards: the
53
+ * control then shows its FIRST option while holding the value it was given.
54
+ * Nothing throws and nothing logs, so the value the user sees is not the
55
+ * value the form will submit — in a form that edits money or tax status
56
+ * that is a wrong record written on save.
57
+ *
58
+ * Marking the option is order-independent, and it is also what makes the
59
+ * choice correct in server-rendered HTML before any script runs.
60
+ */
61
+ const renderOption = (option) => html `<option
62
+ value="${option.value}"
63
+ ?disabled="${option.disabled === true}"
64
+ ?selected="${() => read(props.value) === option.value}"
65
+ >
48
66
  ${option.label}
49
67
  </option>`;
50
68
  return html `<div data-slot="native-select" class="relative inline-flex w-full items-center">
@@ -61,7 +79,14 @@ export const NativeSelect = component((props) => {
61
79
  >
62
80
  ${props.placeholder === undefined
63
81
  ? null
64
- : html `<option value="" disabled selected>${props.placeholder}</option>`}
82
+ : html `<option
83
+ value=""
84
+ disabled
85
+ ?selected="${() => {
86
+ const current = read(props.value);
87
+ return current === undefined || current === "";
88
+ }}"
89
+ >${props.placeholder}</option>`}
65
90
  ${runs().map((run) => run.group === undefined
66
91
  ? run.items.map(renderOption)
67
92
  : html `<optgroup label="${run.group}">${run.items.map(renderOption)}</optgroup>`)}
@@ -1,27 +1,36 @@
1
1
  /**
2
2
  * Enter and exit animations for overlays.
3
3
  *
4
- * shadcn drives these with `animate-in` / `animate-out` from `tw-animate-css`
5
- * a Tailwind plugin, and a dependency. nebula declares the four keyframes it
6
- * needs in `theme.css` and references them through arbitrary animation values,
7
- * which every one of the three adapters supports and none of them needs a
8
- * plugin for.
4
+ * These are shadcn's own class strings, verbatim, and they come from the same
5
+ * place shadcn's do: `tw-animate-css`, which registers `animate-in` /
6
+ * `animate-out` and the `fade-*` / `zoom-*` / `slide-*` modifiers through
7
+ * `@theme inline` and `@utility`.
9
8
  *
10
- * The durations are asymmetric on purpose: entering is slower than leaving.
11
- * A surface appearing wants to be noticed, a surface dismissed wants to be out
12
- * of the way matching them makes closing feel sluggish.
9
+ * nebula used to declare four bespoke keyframes and reference them through
10
+ * ARBITRARY animation values (`animate-[nebula-zoom-out_120ms_ease-in]`), to
11
+ * avoid the plugin. That is what made a missing stylesheet fatal rather than
12
+ * cosmetic: Tailwind compiles an arbitrary value unconditionally, so the
13
+ * `animation` property was always set while the keyframes behind it might exist
14
+ * nowhere — the browser then never fires `animationend` and every closed
15
+ * overlay stays in the document. With a theme-registered utility the class
16
+ * simply is not emitted when the theme is absent, the element animates not at
17
+ * all, and closing is instant. That is the whole reason upstream registers
18
+ * rather than inlines, and it is why the deviation is gone.
13
19
  *
14
- * Every string ends in `motion-reduce:animate-none`. That is not only a
15
- * courtesy: `onExitFinished` reads the computed style to decide whether to
16
- * wait, sees no animation, and removes the node immediately. Reduced motion
17
- * therefore gets an instant close rather than a delayed one, with no branch in
18
- * the component.
20
+ * The durations are shadcn's too, which means asymmetric by way of the sheet
21
+ * variants only; everything else takes `tw-animate-css`'s defaults.
19
22
  */
20
23
  import type { Side } from "../primitives/floating.js";
21
- /** Popovers, menus, selects — scale up from the anchor. */
22
- export declare const zoomInOut = "data-[state=open]:animate-[nebula-zoom-in_150ms_ease-out] data-[state=closed]:animate-[nebula-zoom-out_120ms_ease-in] motion-reduce:animate-none";
24
+ /**
25
+ * Popovers, menus, selects scale up from the anchor.
26
+ *
27
+ * shadcn's dialog and popover string. `motion-reduce:animate-none` is ours and
28
+ * stays: it also makes `onExitFinished` see no animation and remove the node at
29
+ * once, so reduced motion gets an instant close with no branch in the component.
30
+ */
31
+ export declare const zoomInOut = "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95 motion-reduce:animate-none";
23
32
  /** Backdrops and tooltips — no movement, just opacity. */
24
- export declare const fadeInOut = "data-[state=open]:animate-[nebula-fade-in_150ms_ease-out] data-[state=closed]:animate-[nebula-fade-out_120ms_ease-in] motion-reduce:animate-none";
33
+ export declare const fadeInOut = "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 motion-reduce:animate-none";
25
34
  /**
26
35
  * Panels that slide in from an edge — Sheet, Drawer.
27
36
  *
@@ -1,26 +1,35 @@
1
1
  /**
2
2
  * Enter and exit animations for overlays.
3
3
  *
4
- * shadcn drives these with `animate-in` / `animate-out` from `tw-animate-css`
5
- * a Tailwind plugin, and a dependency. nebula declares the four keyframes it
6
- * needs in `theme.css` and references them through arbitrary animation values,
7
- * which every one of the three adapters supports and none of them needs a
8
- * plugin for.
4
+ * These are shadcn's own class strings, verbatim, and they come from the same
5
+ * place shadcn's do: `tw-animate-css`, which registers `animate-in` /
6
+ * `animate-out` and the `fade-*` / `zoom-*` / `slide-*` modifiers through
7
+ * `@theme inline` and `@utility`.
9
8
  *
10
- * The durations are asymmetric on purpose: entering is slower than leaving.
11
- * A surface appearing wants to be noticed, a surface dismissed wants to be out
12
- * of the way matching them makes closing feel sluggish.
9
+ * nebula used to declare four bespoke keyframes and reference them through
10
+ * ARBITRARY animation values (`animate-[nebula-zoom-out_120ms_ease-in]`), to
11
+ * avoid the plugin. That is what made a missing stylesheet fatal rather than
12
+ * cosmetic: Tailwind compiles an arbitrary value unconditionally, so the
13
+ * `animation` property was always set while the keyframes behind it might exist
14
+ * nowhere — the browser then never fires `animationend` and every closed
15
+ * overlay stays in the document. With a theme-registered utility the class
16
+ * simply is not emitted when the theme is absent, the element animates not at
17
+ * all, and closing is instant. That is the whole reason upstream registers
18
+ * rather than inlines, and it is why the deviation is gone.
13
19
  *
14
- * Every string ends in `motion-reduce:animate-none`. That is not only a
15
- * courtesy: `onExitFinished` reads the computed style to decide whether to
16
- * wait, sees no animation, and removes the node immediately. Reduced motion
17
- * therefore gets an instant close rather than a delayed one, with no branch in
18
- * the component.
20
+ * The durations are shadcn's too, which means asymmetric by way of the sheet
21
+ * variants only; everything else takes `tw-animate-css`'s defaults.
19
22
  */
20
- /** Popovers, menus, selects — scale up from the anchor. */
21
- export const zoomInOut = "data-[state=open]:animate-[nebula-zoom-in_150ms_ease-out] data-[state=closed]:animate-[nebula-zoom-out_120ms_ease-in] motion-reduce:animate-none";
23
+ /**
24
+ * Popovers, menus, selects scale up from the anchor.
25
+ *
26
+ * shadcn's dialog and popover string. `motion-reduce:animate-none` is ours and
27
+ * stays: it also makes `onExitFinished` see no animation and remove the node at
28
+ * once, so reduced motion gets an instant close with no branch in the component.
29
+ */
30
+ export const zoomInOut = "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95 motion-reduce:animate-none";
22
31
  /** Backdrops and tooltips — no movement, just opacity. */
23
- export const fadeInOut = "data-[state=open]:animate-[nebula-fade-in_150ms_ease-out] data-[state=closed]:animate-[nebula-fade-out_120ms_ease-in] motion-reduce:animate-none";
32
+ export const fadeInOut = "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 motion-reduce:animate-none";
24
33
  /**
25
34
  * Panels that slide in from an edge — Sheet, Drawer.
26
35
  *
@@ -28,12 +37,9 @@ export const fadeInOut = "data-[state=open]:animate-[nebula-fade-in_150ms_ease-o
28
37
  * depends on the panel's own size and only `translate-x-full` knows that.
29
38
  */
30
39
  export function slideFrom(side) {
31
- const axis = side === "left"
32
- ? "data-[state=closed]:-translate-x-full"
33
- : side === "right"
34
- ? "data-[state=closed]:translate-x-full"
35
- : side === "top"
36
- ? "data-[state=closed]:-translate-y-full"
37
- : "data-[state=closed]:translate-y-full";
38
- return `transition-transform duration-300 ease-in-out data-[state=open]:translate-x-0 data-[state=open]:translate-y-0 ${axis} motion-reduce:transition-none`;
40
+ // shadcn's sheet: a slide utility per side, and its asymmetric durations —
41
+ // entering is slower than leaving, because a surface appearing wants to be
42
+ // noticed while one dismissed wants to be out of the way.
43
+ const slide = `data-[state=open]:slide-in-from-${side} data-[state=closed]:slide-out-to-${side}`;
44
+ return `data-[state=open]:animate-in data-[state=closed]:animate-out ${slide} data-[state=open]:duration-500 data-[state=closed]:duration-300 motion-reduce:animate-none`;
39
45
  }
@@ -16,7 +16,6 @@
16
16
  * required rather than optional — `srOnlyTitle` is there for designs that show
17
17
  * no visible heading.
18
18
  */
19
- import type { Child } from "../lib/children.js";
20
19
  import { type Slot } from "../lib/children.js";
21
20
  import { type Reactive } from "../lib/props.js";
22
21
  export declare const dialogBackdropClasses = "fixed inset-0 z-50 bg-black/50";
@@ -24,9 +23,17 @@ export declare const dialogPanelClasses = "bg-background fixed top-1/2 left-1/2
24
23
  export interface DialogProps {
25
24
  /** Rendered inside the trigger button. Omit to drive `open` yourself. */
26
25
  trigger?: Slot;
27
- /** Announced on open. Hide it visually with `srOnlyTitle`. */
28
- title: Child;
29
- description?: Child;
26
+ /**
27
+ * Announced on open. Hide it visually with `srOnlyTitle`.
28
+ *
29
+ * A `Slot`, so it may be an accessor: one Dialog driven between "create" and
30
+ * "edit" needs a title that follows. It already behaved that way — the
31
+ * renderer binds whatever it is given — while the type said `Child` and
32
+ * refused the function, so the working call did not compile and the type
33
+ * disagreed with `children` beside it for no reason.
34
+ */
35
+ title: Slot;
36
+ description?: Slot;
30
37
  children?: Slot;
31
38
  /** Actions, laid out bottom-right. */
32
39
  footer?: Slot;
@@ -59,14 +59,14 @@ export const Dialog = component((props) => {
59
59
  id="${titleId}"
60
60
  data-slot="dialog-title"
61
61
  class="${cn("text-lg leading-none font-semibold", props.srOnlyTitle === true ? "sr-only" : "")}"
62
- >${props.title}</h2>
62
+ >${slot(props.title)}</h2>
63
63
  ${props.description === undefined
64
64
  ? null
65
65
  : html `<p
66
66
  id="${descriptionId}"
67
67
  data-slot="dialog-description"
68
68
  class="text-muted-foreground text-sm"
69
- >${props.description}</p>`}
69
+ >${slot(props.description)}</p>`}
70
70
  </div>
71
71
  ${slot(props.children)}
72
72
  ${props.footer === undefined
@@ -69,6 +69,15 @@ export interface SidebarMenuItemProps {
69
69
  tooltip?: string;
70
70
  /** A trailing control — the count of unread items, a status dot. */
71
71
  badge?: Child;
72
+ /**
73
+ * Extra classes, merged over the defaults.
74
+ *
75
+ * Every other component in this library takes one; this one did not, and a
76
+ * caller who wanted something as ordinary as dimming a disabled entry had
77
+ * to work around it. `cn` is tailwind-merge, so an override wins over the
78
+ * default it collides with rather than fighting it on specificity.
79
+ */
80
+ class?: Reactive<string>;
72
81
  onClick?: () => void;
73
82
  }
74
83
  /**
@@ -171,18 +171,21 @@ export const SidebarMenuItem = component((props) => {
171
171
  data-slot="sidebar-menu-badge"
172
172
  class="text-sidebar-foreground/70 ml-auto flex h-5 min-w-5 shrink-0 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums"
173
173
  >${props.badge}</span>`}`;
174
+ // Reactive, because `props.class` may be a signal — a caller dimming an
175
+ // entry as its state changes should not have to remount it.
176
+ const merged = () => cn(classes, read(props.class));
174
177
  const entry = props.href !== undefined
175
178
  ? html `<a
176
179
  data-slot="sidebar-menu-item"
177
180
  href="${props.href}"
178
181
  aria-current="${() => (read(props.active) === true ? "page" : undefined)}"
179
- class="${classes}"
182
+ class="${merged}"
180
183
  >${body}</a>`
181
184
  : html `<button
182
185
  type="button"
183
186
  data-slot="sidebar-menu-item"
184
187
  aria-current="${() => (read(props.active) === true ? "page" : undefined)}"
185
- class="${classes}"
188
+ class="${merged}"
186
189
  @click="${props.onClick}"
187
190
  >${body}</button>`;
188
191
  if (props.tooltip === undefined)
@@ -33,17 +33,26 @@ import { onExitFinished } from "./presence.js";
33
33
  export function floatingSurface(options) {
34
34
  let live = null;
35
35
  function show() {
36
- if (live !== null) {
37
- // Already open and mid-exit: cancel the teardown and reuse the node
38
- // rather than stacking a second copy on top of the one fading out.
39
- live.cancelExit?.();
40
- live.cancelExit = null;
41
- live.element.setAttribute("data-state", "open");
42
- return;
43
- }
44
36
  const anchor = options.anchor();
45
37
  if (anchor === null)
46
38
  return;
39
+ if (live !== null) {
40
+ if (live.anchor === anchor) {
41
+ // The SAME anchor, already open and possibly mid-exit: cancel
42
+ // the teardown and reuse the node rather than stacking a second
43
+ // copy on top of the one fading out.
44
+ live.cancelExit?.();
45
+ live.cancelExit = null;
46
+ live.element.setAttribute("data-state", "open");
47
+ return;
48
+ }
49
+ // A DIFFERENT anchor. Content is built once per open, so reusing
50
+ // this node would show the previous anchor's content — one surface
51
+ // shared by the rows of a table showed the first row's entries
52
+ // however many rows were clicked. Its position, dismissal listeners
53
+ // and focus trap all belong to the old anchor too.
54
+ teardown();
55
+ }
47
56
  const mount = portal(options.content());
48
57
  const element = mount.host.firstElementChild;
49
58
  if (!(element instanceof HTMLElement)) {