@stnd/ui 0.5.0 → 0.5.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.
package/Accordion.astro CHANGED
@@ -1,17 +1,19 @@
1
1
  ---
2
2
  /**
3
- * Accordion.astro
3
+ * @component Accordion
4
+ * @description Zero-JS accordion container — ensures only one child
5
+ * `<details>` is open at a time via a plain click listener, no framework
6
+ * hydration required.
4
7
  *
5
- * Renders an accordion container that ensures only one child details is open at a time.
6
- *
7
- * Usage:
8
- * <Accordion>
9
- * <AccordionItem title="Item 1">Content</AccordionItem>
10
- * <AccordionItem title="Item 2">Content</AccordionItem>
11
- * </Accordion>
8
+ * @example astro
9
+ * <Accordion>
10
+ * <AccordionItem title="Item 1">Content</AccordionItem>
11
+ * <AccordionItem title="Item 2">Content</AccordionItem>
12
+ * </Accordion>
12
13
  */
13
14
 
14
15
  interface Props {
16
+ /** Extra class(es) for the container. */
15
17
  class?: string;
16
18
  id?: string;
17
19
  }
@@ -1,19 +1,22 @@
1
1
  ---
2
2
  /**
3
- * AccordionItem.astro
3
+ * @component AccordionItem
4
+ * @description One collapsible section inside an `<Accordion>` — a plain
5
+ * `<details>`/`<summary>` pair, so it works even without JS.
4
6
  *
5
- * Renders an individual accordion section.
6
- *
7
- * Usage:
8
- * <AccordionItem title="Is it accessible?" headingLevel="h3">
9
- * <p>Yes. It adheres to the WAI-ARIA design pattern.</p>
10
- * </AccordionItem>
7
+ * @example astro
8
+ * <AccordionItem title="Is it accessible?" headingLevel="h3">
9
+ * <p>Yes. It adheres to the WAI-ARIA design pattern.</p>
10
+ * </AccordionItem>
11
11
  */
12
12
  import Icon from "@stnd/icon/Icon.astro";
13
13
 
14
14
  interface Props {
15
+ /** Title text — ignored if the `title` slot is used instead. */
15
16
  title?: string;
17
+ /** Heading tag wrapping the title. Default: `h3`. */
16
18
  headingLevel?: "h2" | "h3" | "h4" | "h5" | "h6" | "div";
19
+ /** Initial open state. Default: `false`. */
17
20
  open?: boolean;
18
21
  class?: string;
19
22
  }
package/Alert.svelte CHANGED
@@ -1,19 +1,23 @@
1
1
  <script>
2
2
  /**
3
- * @stnd/ui/Alert.svelte
4
- * Standard UI Alert — inline notification banner.
3
+ * @component Alert
4
+ * @description Inline notification banner. Wraps the global `.alert` CSS
5
+ * class — use the `class` prop for semantic color variants.
5
6
  *
6
- * Wraps the global `.alert` CSS class. Use the `class` prop to apply
7
- * semantic color variants: "success" | "warning" | "error" | "info"
7
+ * @property {string} [class] - Semantic intent: "success" | "warning" | "error" | "info". Defaults to accent color.
8
+ * @property {string} [title] - Optional bold title.
9
+ * @property {string} [icon] - Optional string icon (e.g. "✓", "⚠").
10
+ * @property {string} [style] - Extra inline style.
11
+ * @property {import('svelte').Snippet} [children] - Banner content.
8
12
  *
9
- * @example
10
- * <Alert>Basic message</Alert>
11
- * <Alert class="success" icon="✓" title="Saved">Changes saved.</Alert>
12
- * <Alert class="error" icon="✕" title="Failed">Something went wrong.</Alert>
13
+ * @example svelte
14
+ * <Alert>Basic message</Alert>
15
+ * <Alert class="success" icon="✓" title="Saved">Changes saved.</Alert>
16
+ * <Alert class="error" icon="✕" title="Failed">Something went wrong.</Alert>
13
17
  */
14
18
  let {
15
- icon,
16
- title,
19
+ icon = "",
20
+ title = "",
17
21
  class: className = "",
18
22
  style = "",
19
23
  children,
@@ -1,7 +1,18 @@
1
1
  <script>
2
2
  /**
3
- * @stnd/ui/AlertDialog.svelte
4
- * Standard UI Alert Dialog
3
+ * @component AlertDialog
4
+ * @description A native `<dialog>`-based confirmation modal. Used
5
+ * internally by `DialogManager`/`confirm()` — most consumers should call
6
+ * `confirm()` from `@stnd/ui/dialog.js` instead of mounting this directly.
7
+ *
8
+ * @property {boolean} [open] - Bindable open state.
9
+ * @property {string} [title] - Dialog title.
10
+ * @property {string} [description] - Dialog body text.
11
+ * @property {string} [confirmLabel] - Confirm button label.
12
+ * @property {string} [cancelLabel] - Cancel button label.
13
+ * @property {'neutral' | 'danger'} [intent] - Confirm button styling.
14
+ * @property {() => void} [onconfirm] - Called when confirmed.
15
+ * @property {() => void} [oncancel] - Called when cancelled or dismissed.
5
16
  */
6
17
  let {
7
18
  open = $bindable(false),
@@ -34,12 +45,6 @@
34
45
  onconfirm?.();
35
46
  }
36
47
 
37
- function handleKeydown(e) {
38
- if (e.key === "Escape") {
39
- e.preventDefault(); // Prevent default browser escape (which just closes it) so we can sync state
40
- handleClose();
41
- }
42
- }
43
48
  </script>
44
49
 
45
50
  <!-- svelte-ignore a11y_click_events_have_key_events -->
@@ -47,7 +52,9 @@
47
52
  <dialog
48
53
  bind:this={dialog}
49
54
  class="std-dialog"
50
- onclose={handleClose}
55
+ aria-label={title}
56
+ onkeydown={(event) => event.stopPropagation()}
57
+ onclose={() => { if (open) handleClose(); }}
51
58
  oncancel={(e) => {
52
59
  e.preventDefault();
53
60
  handleClose();
package/CfImage.astro CHANGED
@@ -1,36 +1,42 @@
1
1
  ---
2
2
  /**
3
- * CfImage — Responsive image via Cloudflare Image Transformations.
3
+ * @component CfImage
4
+ * @description Responsive image via Cloudflare Image Transformations.
5
+ * Generates a proper `<img srcset="..." sizes="...">` using Cloudflare's
6
+ * `/cdn-cgi/image/` pipeline. Falls back to the original `src` in dev.
4
7
  *
5
- * Generates a proper <img srcset="..." sizes="..."> using Cloudflare's
6
- * /cdn-cgi/image/ pipeline. Falls back to the original src in dev.
8
+ * @example astro - Basic
9
+ * <CfImage src="/assets/img/test.png" alt="Hero" sizes="100vw" />
7
10
  *
8
- * @example Basic
9
- * <CfImage src="/assets/img/hero.jpg" alt="Hero" sizes="100vw" />
10
- *
11
- * @example Card thumbnail (image is ~1/3 of viewport on desktop)
12
- * <CfImage
13
- * src={cover}
14
- * alt={title}
15
- * sizes="(max-width: 600px) 100vw, (max-width: 900px) 50vw, 33vw"
16
- * loading="eager"
17
- * />
11
+ * @example astro - Card thumbnail (image is ~1/3 of viewport on desktop)
12
+ * <CfImage
13
+ * src={cover}
14
+ * alt={title}
15
+ * sizes="(max-width: 600px) 100vw, (max-width: 900px) 50vw, 33vw"
16
+ * loading="eager"
17
+ * />
18
18
  */
19
19
  import { cfImage, cfImageSrcset } from "@stnd/utils";
20
20
 
21
21
  interface Props {
22
+ /** Image source path. */
22
23
  src: string;
24
+ /** Alt text — required, no silent fallback. */
23
25
  alt: string;
24
26
  /** CSS sizes attribute — describes the rendered image width at each breakpoint. */
25
27
  sizes?: string;
26
28
  /** Widths (px) to include in the srcset. */
27
29
  widths?: number[];
30
+ /** Cloudflare output quality (0–100). */
28
31
  quality?: number;
32
+ /** Output format passed to Cloudflare (e.g. `avif`, `webp`, `auto`). */
29
33
  format?: string;
30
34
  /** Cloudflare sharpen (0–10). */
31
35
  sharpen?: number;
32
36
  class?: string;
37
+ /** Native `loading` attribute. */
33
38
  loading?: "lazy" | "eager";
39
+ /** Native `decoding` attribute. */
34
40
  decoding?: "async" | "sync" | "auto";
35
41
  [key: string]: any;
36
42
  }
package/Combobox.svelte CHANGED
@@ -1,23 +1,29 @@
1
1
  <script>
2
2
  import { setContext } from "svelte";
3
3
 
4
+ /**
5
+ * @component Combobox
6
+ * @description Searchable select — type to filter `options`, or type a
7
+ * value not in the list when `allowCustom` is set.
8
+ */
9
+
4
10
  /**
5
11
  * @typedef {Object} ComboboxOption
6
- * @property {string} label
7
- * @property {string} value
12
+ * @property {string} label - Text shown in the list.
13
+ * @property {string} value - Value stored when this option is selected.
8
14
  */
9
15
 
10
16
  /**
11
17
  * @typedef {Object} ComboboxProps
12
- * @property {ComboboxOption[]} options
13
- * @property {string} [value]
14
- * @property {string} [placeholder]
15
- * @property {string} [searchPlaceholder]
16
- * @property {string} [emptyText]
17
- * @property {boolean} [allowCustom]
18
- * @property {string} [class]
19
- * @property {string} [name]
20
- * @property {string} [id]
18
+ * @property {ComboboxOption[]} options - The list to search/select from.
19
+ * @property {string} [value] - Bindable selected value.
20
+ * @property {string} [placeholder] - Placeholder before a value is selected.
21
+ * @property {string} [searchPlaceholder] - Placeholder inside the search input.
22
+ * @property {string} [emptyText] - Shown when no option matches the search.
23
+ * @property {boolean} [allowCustom] - Accept a typed value not present in `options`.
24
+ * @property {string} [class] - Extra class(es) for the trigger.
25
+ * @property {string} [name] - Name for an associated hidden form input.
26
+ * @property {string} [id] - ID for the trigger button.
21
27
  */
22
28
 
23
29
  /** @type {ComboboxProps} */
@@ -1,120 +1,105 @@
1
1
  <script>
2
- import { setContext } from "svelte";
2
+ import { setContext, tick } from "svelte";
3
+ import "./menu.css";
4
+ import { clampMenuPosition, menuItems, navigateMenu } from "./menu.js";
3
5
 
4
6
  /**
5
- * @typedef {Object} ContextMenuProps
6
- * @property {import('svelte').Snippet} children - The trigger element(s)
7
- * @property {import('svelte').Snippet} [content] - The menu content
8
- * @property {string} [class] - Optional class for the trigger wrapper
7
+ * Right-click menu, or controlled menu for virtualized/delegated targets.
8
+ * `position` uses viewport coordinates. `onclose` synchronizes a controlled target.
9
+ * Existing children/content snippets remain supported.
9
10
  */
10
-
11
- /** @type {ContextMenuProps} */
12
- let { children, content, class: className = "", ...props } = $props();
13
-
14
- let open = $state(false);
15
- let positioned = $state(false);
16
- let x = $state(0);
17
- let y = $state(0);
11
+ let {
12
+ children = undefined, content = undefined, class: className = "", open = $bindable(false),
13
+ position = null, label = "Actions", onclose = () => {}, ...props
14
+ } = $props();
15
+ let point = $state({ x: 0, y: 0 });
16
+ let location = $state({ x: 0, y: 0 });
17
+ let placed = $state(false);
18
18
  let menuElement = $state();
19
-
20
- function onContextMenu(e) {
21
- if (e.ctrlKey) return;
22
- e.preventDefault();
23
-
24
- let targetX = e.clientX;
25
- let targetY = e.clientY;
26
-
27
- x = targetX;
28
- y = targetY;
29
- open = true;
30
- positioned = false;
31
-
32
- // Use a microtask/timeout to wait for render then measure
33
- setTimeout(() => {
34
- if (!menuElement) return;
35
-
36
- const rect = menuElement.getBoundingClientRect();
37
- const viewportWidth = window.innerWidth;
38
- const viewportHeight = window.innerHeight;
39
-
40
- if (targetX + rect.width > viewportWidth) {
41
- targetX -= rect.width;
42
- }
43
- if (targetY + rect.height > viewportHeight) {
44
- targetY -= rect.height;
45
- }
46
-
47
- x = targetX;
48
- y = targetY;
49
- positioned = true;
50
- }, 0);
51
- }
19
+ let returnFocus;
52
20
 
53
21
  function close() {
54
22
  open = false;
55
- positioned = false;
23
+ onclose();
56
24
  }
57
25
 
58
- setContext("context-menu", {
59
- get open() {
60
- return open;
61
- },
62
- close,
63
- });
64
-
65
- function handleWindowClick(e) {
66
- if (open && menuElement && !menuElement.contains(e.target)) {
67
- close();
68
- }
26
+ function onContextMenu(event) {
27
+ // Keep Control-click native for existing consumers.
28
+ if (event.ctrlKey) return;
29
+ event.preventDefault();
30
+ event.stopPropagation();
31
+ point = { x: event.clientX, y: event.clientY };
32
+ open = true;
69
33
  }
70
34
 
71
- function handleWindowKeydown(e) {
72
- if (open && e.key === "Escape") {
73
- close();
74
- }
75
- }
35
+ $effect(() => {
36
+ if (!open || !menuElement) return;
37
+ const anchor = position ?? point;
38
+ const element = menuElement;
39
+ returnFocus = document.activeElement;
40
+ placed = false;
41
+ let cancelled = false;
42
+ let focusFrame;
43
+ const onScroll = (event) => { if (!element.contains(event.target)) close(); };
44
+ window.addEventListener("scroll", onScroll, true);
45
+ tick().then(() => {
46
+ if (cancelled) return;
47
+ location = clampMenuPosition(anchor.x, anchor.y, element.offsetWidth, element.offsetHeight, window.innerWidth, window.innerHeight);
48
+ placed = true;
49
+ // Measure without inherited visibility:hidden: menu items transition
50
+ // "all", which can leave their visibility hidden when focus is requested.
51
+ focusFrame = requestAnimationFrame(() => {
52
+ if (!cancelled) (menuItems(element)[0] ?? element).focus();
53
+ });
54
+ });
55
+ return () => {
56
+ cancelled = true;
57
+ cancelAnimationFrame(focusFrame);
58
+ window.removeEventListener("scroll", onScroll, true);
59
+ if (element.contains(document.activeElement) || document.activeElement === document.body) {
60
+ returnFocus?.focus?.();
61
+ }
62
+ };
63
+ });
64
+
65
+ setContext("context-menu", { get open() { return open; }, close });
76
66
  </script>
77
67
 
78
68
  <svelte:window
79
- onclick={handleWindowClick}
80
- onkeydown={handleWindowKeydown}
81
- onscroll={close}
69
+ onclick={(event) => { if (open && !menuElement?.contains(event.target)) close(); }}
70
+ onresize={() => { if (open) close(); }}
82
71
  />
83
72
 
84
- <div
85
- class="context-menu-trigger {className}"
86
- oncontextmenu={onContextMenu}
87
- role="presentation"
88
- {...props}
89
- >
90
- {@render children?.()}
91
- </div>
92
-
73
+ {#if children}
74
+ <div class="context-menu-trigger {className}" oncontextmenu={onContextMenu} role="presentation" {...props}>
75
+ {@render children()}
76
+ </div>
77
+ {/if}
93
78
  {#if open}
94
79
  <div
95
80
  bind:this={menuElement}
96
81
  class="std-menu-content context-menu-content m-0 no-rhythm"
97
- style:left="{x}px"
98
- style:top="{y}px"
99
- style:visibility={positioned ? "visible" : "hidden"}
100
- role="menu"
101
- tabindex="-1"
82
+ style:left="{location.x}px"
83
+ style:top="{location.y}px"
84
+ style:opacity={placed ? 1 : 0}
85
+ style:pointer-events={placed ? "auto" : "none"}
86
+ role="menu" aria-label={label} tabindex="-1"
87
+ onkeydown={(event) => navigateMenu(event, menuElement, close)}
88
+ oncontextmenu={(event) => event.preventDefault()}
102
89
  >
103
90
  {@render content?.()}
104
91
  </div>
105
92
  {/if}
106
93
 
107
94
  <style>
108
- .context-menu-trigger {
109
- display: contents;
110
- }
111
-
95
+ .context-menu-trigger { display: contents; }
112
96
  .context-menu-content {
113
97
  position: fixed;
98
+ z-index: 10000;
114
99
  min-width: var(--space-6);
100
+ max-width: calc(100vw - 16px);
101
+ max-height: calc(100vh - 16px);
102
+ overflow-y: auto;
115
103
  }
116
-
117
- :global(.context-menu-content > *) {
118
- flex-shrink: 0;
119
- }
104
+ :global(.context-menu-content > *) { flex-shrink: 0; }
120
105
  </style>
@@ -2,6 +2,11 @@
2
2
  import { getContext } from "svelte";
3
3
  import Icon from "@stnd/icon/Icon.svelte";
4
4
 
5
+ /**
6
+ * @component ContextMenuItem
7
+ * @description One clickable row inside a `<ContextMenu>`'s `content` slot.
8
+ */
9
+
5
10
  /**
6
11
  * @typedef {Object} ContextMenuItemProps
7
12
  * @property {import('svelte').Snippet} [children] - Item label
@@ -13,7 +18,7 @@
13
18
  * @property {() => void} [onclick] - Click handler
14
19
  */
15
20
 
16
- /** @type {ContextMenuItemProps} */
21
+ /** @type {ContextMenuItemProps & import('svelte/elements').HTMLButtonAttributes} */
17
22
  let {
18
23
  children,
19
24
  label,
@@ -42,6 +47,7 @@
42
47
  onclick={handleClick}
43
48
  role="menuitem"
44
49
  aria-disabled={disabled}
50
+ {disabled}
45
51
  {...props}
46
52
  >
47
53
  {#if icon}
@@ -2,6 +2,11 @@
2
2
  import { getContext } from "svelte";
3
3
  import Icon from "@stnd/icon/Icon.svelte";
4
4
 
5
+ /**
6
+ * @component ContextMenuItemCheckbox
7
+ * @description A checkable row inside a `<ContextMenu>`'s `content` slot.
8
+ */
9
+
5
10
  /**
6
11
  * @typedef {Object} ContextMenuItemCheckboxProps
7
12
  * @property {import('svelte').Snippet} [children] - Item label
@@ -42,6 +47,7 @@
42
47
  role="menuitemcheckbox"
43
48
  aria-checked={checked}
44
49
  aria-disabled={disabled}
50
+ {disabled}
45
51
  {...props}
46
52
  >
47
53
  <div class="item-check">
@@ -1,4 +1,10 @@
1
1
  <script>
2
+ /**
3
+ * @component ContextMenuLabel
4
+ * @description A non-interactive heading row inside a `<ContextMenu>`,
5
+ * used to group related items.
6
+ */
7
+
2
8
  /**
3
9
  * @typedef {Object} ContextMenuLabelProps
4
10
  * @property {import('svelte').Snippet} [children] - Label content
@@ -1 +1,8 @@
1
+ <script>
2
+ /**
3
+ * @component ContextMenuSeparator
4
+ * @description A visual divider between items in a `<ContextMenu>`. No props.
5
+ */
6
+ </script>
7
+
1
8
  <hr class="std-menu-separator" />
package/Dialog.svelte ADDED
@@ -0,0 +1,60 @@
1
+ <script>
2
+ import { tick } from "svelte";
3
+
4
+ /** Native modal shell. Content/layout remain the caller's responsibility. */
5
+ let {
6
+ open = $bindable(false), label, children, class: className = "",
7
+ style = "", onclose = () => {}, onkeydown = undefined,
8
+ } = $props();
9
+ let element = $state();
10
+
11
+ function close() {
12
+ if (!open) return;
13
+ open = false;
14
+ onclose();
15
+ }
16
+
17
+ $effect(() => {
18
+ if (!element || !open) return;
19
+ const dialog = element;
20
+ const previous = document.activeElement;
21
+ dialog.showModal();
22
+ return () => {
23
+ dialog.close();
24
+ tick().then(() => { if (previous?.isConnected) previous.focus?.(); });
25
+ };
26
+ });
27
+ </script>
28
+
29
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
30
+ <dialog bind:this={element} class="std-modal {className}" {style} aria-label={label}
31
+ oncancel={(event) => { event.preventDefault(); close(); }}
32
+ onclose={() => { if (element && !element.open) close(); }}
33
+ onkeydown={(event) => { event.stopPropagation(); onkeydown?.(event); }}
34
+ onclick={(event) => {
35
+ if (event.target !== element) return;
36
+ const rect = element.getBoundingClientRect();
37
+ if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) close();
38
+ }}
39
+ >
40
+ {@render children?.()}
41
+ </dialog>
42
+
43
+ <style>
44
+ .std-modal {
45
+ padding: 0;
46
+ margin: auto;
47
+ width: var(--dialog-width, min(90vw, 580px));
48
+ max-width: calc(100vw - 32px);
49
+ height: var(--dialog-height, auto);
50
+ max-height: var(--dialog-max-height, 88vh);
51
+ overflow: hidden;
52
+ background: var(--color-surface-low);
53
+ color: var(--color-foreground);
54
+ border: 1px solid var(--color-border);
55
+ border-radius: var(--radius-lg);
56
+ box-shadow: var(--shadow-raised);
57
+ }
58
+ .std-modal[open] { display: flex; flex-direction: column; }
59
+ .std-modal::backdrop { background: rgb(0 0 0 / 0.65); backdrop-filter: blur(8px); }
60
+ </style>
@@ -1,4 +1,16 @@
1
1
  <script>
2
+ /**
3
+ * @component DialogManager
4
+ * @description Renders confirmation dialogs triggered programmatically —
5
+ * mount `<DialogManagerComponent client:load />` once in your root layout
6
+ * (Standard does this by default), then call `confirm()` from
7
+ * `@stnd/ui/dialog.js` anywhere; no props, no per-dialog markup. Bridges
8
+ * the `dialogState` store (set by `confirm()`) to an `<AlertDialog>`.
9
+ *
10
+ * @example js
11
+ * import { confirm } from "@stnd/ui/dialog.js";
12
+ * const ok = await confirm({ title: "Delete this?", intent: "danger" });
13
+ */
2
14
  import AlertDialog from "./AlertDialog.svelte";
3
15
  import { dialogState } from "./dialog.js";
4
16