@stnd/ui 0.5.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.
@@ -0,0 +1,105 @@
1
+ <script>
2
+ import { getContext } from "svelte";
3
+ import Icon from "@stnd/icon/Icon.svelte";
4
+
5
+ /**
6
+ * @typedef {Object} ContextMenuItemCheckboxProps
7
+ * @property {import('svelte').Snippet} [children] - Item label
8
+ * @property {string} [label] - Label as a string
9
+ * @property {boolean} [checked] - Checked state
10
+ * @property {boolean} [disabled] - Disabled state
11
+ * @property {(checked: boolean) => void} [onCheckedChange] - Change handler
12
+ */
13
+
14
+ /** @type {ContextMenuItemCheckboxProps} */
15
+ let {
16
+ children,
17
+ label,
18
+ checked = $bindable(false),
19
+ disabled = false,
20
+ onCheckedChange,
21
+ ...props
22
+ } = $props();
23
+
24
+ const ctx = getContext("context-menu");
25
+
26
+ function handleClick(e) {
27
+ if (disabled) return;
28
+ checked = !checked;
29
+ onCheckedChange?.(checked);
30
+ // Usually checkboxes don't close the menu immediately in all implementations,
31
+ // but for context menus they often do, or stay open if you use e.stopPropagation().
32
+ // We'll follow the standard and close it unless the user prevents it.
33
+ ctx?.close();
34
+ }
35
+ </script>
36
+
37
+ <button
38
+ type="button"
39
+ class="context-menu-item"
40
+ class:disabled
41
+ onclick={handleClick}
42
+ role="menuitemcheckbox"
43
+ aria-checked={checked}
44
+ aria-disabled={disabled}
45
+ {...props}
46
+ >
47
+ <div class="item-check">
48
+ {#if checked}
49
+ <Icon icon="check" />
50
+ {/if}
51
+ </div>
52
+
53
+ <span class="item-label">
54
+ {#if children}
55
+ {@render children()}
56
+ {:else}
57
+ {label}
58
+ {/if}
59
+ </span>
60
+ </button>
61
+
62
+ <style>
63
+ .context-menu-item {
64
+ all: unset;
65
+ display: flex;
66
+ width: 100%;
67
+ box-sizing: border-box;
68
+ align-items: center;
69
+ gap: 0;
70
+ padding: 0 var(--space-d4);
71
+ border-radius: var(--radius-sm);
72
+ font-size: var(--size-sm);
73
+ font-family: var(--font-interface);
74
+ color: var(--color-foreground);
75
+ cursor: pointer;
76
+ user-select: none;
77
+ transition: all var(--transition-fast);
78
+ min-height: var(--space);
79
+ }
80
+
81
+ .context-menu-item:hover:not(.disabled) {
82
+ background: var(--color-surface-high);
83
+ box-shadow: var(--shadow-raised);
84
+ }
85
+ .context-menu-item:active:not(.disabled) {
86
+ background: var(--color-surface-low);
87
+ box-shadow: var(--shadow-inset);
88
+ }
89
+
90
+ .context-menu-item.disabled {
91
+ opacity: 0.5;
92
+ cursor: not-allowed;
93
+ }
94
+
95
+ .item-check {
96
+ display: flex;
97
+ align-items: center;
98
+ justify-content: center;
99
+ }
100
+
101
+ .item-label {
102
+ flex: 1;
103
+ white-space: nowrap;
104
+ }
105
+ </style>
@@ -0,0 +1,18 @@
1
+ <script>
2
+ /**
3
+ * @typedef {Object} ContextMenuLabelProps
4
+ * @property {import('svelte').Snippet} [children] - Label content
5
+ * @property {string} [label] - Label as a string
6
+ */
7
+
8
+ /** @type {ContextMenuLabelProps} */
9
+ let { children, label } = $props();
10
+ </script>
11
+
12
+ <div class="std-menu-label">
13
+ {#if children}
14
+ {@render children()}
15
+ {:else}
16
+ {label}
17
+ {/if}
18
+ </div>
@@ -0,0 +1 @@
1
+ <hr class="std-menu-separator" />
@@ -0,0 +1,32 @@
1
+ <script>
2
+ import AlertDialog from "./AlertDialog.svelte";
3
+ import { dialogState } from "./dialog.js";
4
+
5
+ let state = $state({ open: false });
6
+
7
+ // Sync store to local state
8
+ $effect(() => {
9
+ const unsub = dialogState.subscribe((s) => {
10
+ state = s;
11
+ });
12
+ return unsub;
13
+ });
14
+
15
+ // Sync local open state back to store (when dialog closes internally)
16
+ function handleOpenChange(isOpen) {
17
+ if (!isOpen && state.open) {
18
+ dialogState.update(s => ({ ...s, open: false }));
19
+ }
20
+ }
21
+ </script>
22
+
23
+ <AlertDialog
24
+ open={state.open}
25
+ title={state.title}
26
+ description={state.description}
27
+ confirmLabel={state.confirmLabel}
28
+ cancelLabel={state.cancelLabel}
29
+ intent={state.intent}
30
+ onconfirm={state.onconfirm}
31
+ oncancel={state.oncancel}
32
+ />
@@ -0,0 +1,107 @@
1
+ <script>
2
+ import { setContext } from "svelte";
3
+
4
+ /**
5
+ * @typedef {Object} DropdownProps
6
+ * @property {import('svelte').Snippet} trigger - The trigger element
7
+ * @property {import('svelte').Snippet} children - The menu items
8
+ * @property {'start' | 'end'} [align] - Alignment of the dropdown
9
+ * @property {string} [class] - Optional class
10
+ */
11
+
12
+ /** @type {DropdownProps} */
13
+ let {
14
+ trigger,
15
+ children,
16
+ align = "start",
17
+ class: className = "",
18
+ ...props
19
+ } = $props();
20
+
21
+ let open = $state(false);
22
+ let triggerElement = $state();
23
+ let menuElement = $state();
24
+
25
+ function toggle() {
26
+ open = !open;
27
+ }
28
+
29
+ function close() {
30
+ open = false;
31
+ }
32
+
33
+ setContext("dropdown", {
34
+ close,
35
+ });
36
+
37
+ function handleWindowClick(e) {
38
+ if (
39
+ open &&
40
+ menuElement &&
41
+ !menuElement.contains(e.target) &&
42
+ (!triggerElement || !triggerElement.contains(e.target))
43
+ ) {
44
+ close();
45
+ }
46
+ }
47
+
48
+ function handleKeydown(e) {
49
+ if (!open) return;
50
+ if (e.key === "Escape") {
51
+ close();
52
+ triggerElement?.focus();
53
+ }
54
+ }
55
+ </script>
56
+
57
+ <svelte:window onclick={handleWindowClick} onkeydown={handleKeydown} />
58
+
59
+ <div class="std-dropdown {className}" {...props}>
60
+ <div
61
+ bind:this={triggerElement}
62
+ class="std-dropdown-trigger"
63
+ onclick={toggle}
64
+ role="button"
65
+ tabindex="0"
66
+ onkeydown={(e) => (e.key === "Enter" || e.key === " ") && toggle()}
67
+ aria-haspopup="menu"
68
+ aria-expanded={open}
69
+ >
70
+ {@render trigger?.()}
71
+ </div>
72
+
73
+ {#if open}
74
+ <div
75
+ bind:this={menuElement}
76
+ class="std-menu-content std-dropdown-content no-rhythm"
77
+ class:align-end={align === "end"}
78
+ role="menu"
79
+ >
80
+ {@render children?.()}
81
+ </div>
82
+ {/if}
83
+ </div>
84
+
85
+ <style>
86
+ .std-dropdown {
87
+ position: relative;
88
+ display: inline-block;
89
+ }
90
+
91
+ .std-dropdown-trigger {
92
+ display: inline-flex;
93
+ cursor: pointer;
94
+ }
95
+
96
+ .std-dropdown-content {
97
+ position: absolute;
98
+ top: 100%;
99
+ margin-top: var(--space-d2);
100
+ min-width: var(--space-6);
101
+
102
+ &.align-end {
103
+ right: 0;
104
+ transform-origin: top right;
105
+ }
106
+ }
107
+ </style>
@@ -0,0 +1,87 @@
1
+ <script>
2
+ import { getContext } from "svelte";
3
+ import Icon from "@stnd/icon/Icon.svelte";
4
+
5
+ /**
6
+ * @typedef {Object} DropdownItemProps
7
+ * @property {import('svelte').Snippet} [children] - Item label
8
+ * @property {string} [label] - Label as a string
9
+ * @property {string} [icon] - Icon name
10
+ * @property {string} [shortcut] - Shortcut text (e.g. ⌘C)
11
+ * @property {boolean} [disabled] - Disabled state
12
+ * @property {boolean} [destructive] - Destructive state
13
+ * @property {string} [href] - Link URL
14
+ * @property {(e: MouseEvent) => void} [onclick] - Click handler
15
+ */
16
+
17
+ /** @type {DropdownItemProps} */
18
+ let {
19
+ children,
20
+ label,
21
+ icon,
22
+ shortcut,
23
+ disabled = false,
24
+ destructive = false,
25
+ href,
26
+ onclick,
27
+ ...props
28
+ } = $props();
29
+
30
+ const ctx = getContext("dropdown");
31
+
32
+ function handleClick(e) {
33
+ if (disabled) {
34
+ e.preventDefault();
35
+ return;
36
+ }
37
+ onclick?.(e);
38
+ ctx?.close();
39
+ }
40
+ </script>
41
+
42
+ {#snippet content()}
43
+ {#if icon}
44
+ <div class="item-icon">
45
+ <Icon {icon} width="1em" height="1em" />
46
+ </div>
47
+ {/if}
48
+
49
+ <span class="item-label">
50
+ {#if children}
51
+ {@render children()}
52
+ {:else}
53
+ {label}
54
+ {/if}
55
+ </span>
56
+
57
+ {#if shortcut}
58
+ <span class="item-shortcut">{shortcut}</span>
59
+ {/if}
60
+ {/snippet}
61
+
62
+ {#if href && !disabled}
63
+ <a
64
+ {href}
65
+ class="std-menu-item"
66
+ class:destructive
67
+ onclick={handleClick}
68
+ role="menuitem"
69
+ {...props}
70
+ >
71
+ {@render content()}
72
+ </a>
73
+ {:else}
74
+ <button
75
+ type="button"
76
+ class="std-menu-item"
77
+ class:disabled
78
+ class:destructive
79
+ onclick={handleClick}
80
+ role="menuitem"
81
+ aria-disabled={disabled}
82
+ {disabled}
83
+ {...props}
84
+ >
85
+ {@render content()}
86
+ </button>
87
+ {/if}
@@ -0,0 +1,11 @@
1
+ <script>
2
+ let { children, label, ...props } = $props();
3
+ </script>
4
+
5
+ <div class="std-menu-label" {...props}>
6
+ {#if children}
7
+ {@render children()}
8
+ {:else}
9
+ {label}
10
+ {/if}
11
+ </div>
@@ -0,0 +1 @@
1
+ <hr class="std-menu-separator" role="separator" />
@@ -0,0 +1,35 @@
1
+ ---
2
+ /**
3
+ * LauncherHint.astro
4
+ *
5
+ * Renders a small launcher hint button (⌘K).
6
+ * Logic for visibility/positioning is now handled by the container (e.g. Header slots).
7
+ */
8
+ const {
9
+ title = "Open Launcher — Cmd/Ctrl+K",
10
+ kbd = "⌘K",
11
+ class: className = "",
12
+ } = Astro.props;
13
+ ---
14
+
15
+ <button
16
+ class={`smaller ghost text-subtle ${className}`}
17
+ aria-label={title}
18
+ title={title}
19
+ type="button"
20
+ data-launcher-toggle
21
+ >
22
+ <span class="launcher-hint" aria-hidden="true">{kbd}</span>
23
+ </button>
24
+
25
+ <style>
26
+ .launcher-hint {
27
+ cursor: pointer;
28
+ user-select: none;
29
+ /* Ensure it behaves as a simple button */
30
+ display: inline-flex;
31
+ align-items: center;
32
+ justify-content: center;
33
+ text-box-trim: none;
34
+ }
35
+ </style>
@@ -0,0 +1,154 @@
1
+ ---
2
+ /**
3
+ * Pagination.astro — Zero-JS pagination nav
4
+ *
5
+ * Renders a previous / page numbers / next navigation bar.
6
+ * Uses standard button classes (ghost, outline) from @stnd/styles.
7
+ *
8
+ * Usage:
9
+ * <Pagination currentPage={2} totalPages={5} baseUrl="/chroniques/page" firstPageUrl="/chroniques" />
10
+ *
11
+ * URL pattern:
12
+ * Page 1 → firstPageUrl (or baseUrl if omitted)
13
+ * Page N → baseUrl/N (e.g. /chroniques/page/2)
14
+ */
15
+ import Icon from "@stnd/icon/Icon.astro";
16
+
17
+ interface Props {
18
+ currentPage: number;
19
+ totalPages: number;
20
+ baseUrl: string;
21
+ /** URL for page 1 when it differs from baseUrl (e.g. "/chroniques" vs "/chroniques/page") */
22
+ firstPageUrl?: string;
23
+ class?: string;
24
+ }
25
+
26
+ const {
27
+ currentPage,
28
+ totalPages,
29
+ baseUrl,
30
+ firstPageUrl,
31
+ class: className,
32
+ } = Astro.props;
33
+
34
+ function pageUrl(page: number) {
35
+ return page === 1 ? (firstPageUrl ?? baseUrl) : `${baseUrl}/${page}`;
36
+ }
37
+
38
+ /** Build the list of page numbers to show, with ellipsis gaps. */
39
+ function getPageNumbers(
40
+ current: number,
41
+ total: number,
42
+ ): (number | "ellipsis")[] {
43
+ if (total <= 7) {
44
+ return Array.from({ length: total }, (_, i) => i + 1);
45
+ }
46
+ const pages: (number | "ellipsis")[] = [1];
47
+ if (current > 3) pages.push("ellipsis");
48
+ for (
49
+ let i = Math.max(2, current - 1);
50
+ i <= Math.min(total - 1, current + 1);
51
+ i++
52
+ ) {
53
+ pages.push(i);
54
+ }
55
+ if (current < total - 2) pages.push("ellipsis");
56
+ pages.push(total);
57
+ return pages;
58
+ }
59
+
60
+ const pages = getPageNumbers(currentPage, totalPages);
61
+ const hasPrev = currentPage > 1;
62
+ const hasNext = currentPage < totalPages;
63
+ ---
64
+
65
+ {
66
+ totalPages > 1 && (
67
+ <nav
68
+ role="navigation"
69
+ aria-label="pagination"
70
+ class:list={["stnd-pagination no-bullet no-rhythm text-sm", className]}
71
+ >
72
+ <ul>
73
+ <li>
74
+ {hasPrev ? (
75
+ <a href={pageUrl(currentPage - 1)} class="btn ghost">
76
+ <Icon icon="lucide:chevron-left" />
77
+ Précédent
78
+ </a>
79
+ ) : (
80
+ <span class="btn ghost" aria-disabled="true">
81
+ <Icon icon="lucide:chevron-left" />
82
+ Précédent
83
+ </span>
84
+ )}
85
+ </li>
86
+
87
+ {pages.map((p) =>
88
+ p === "ellipsis" ? (
89
+ <li aria-hidden="true">
90
+ <span class="btn ghost icon ellipsis">
91
+ <Icon icon="lucide:ellipsis" />
92
+ </span>
93
+ </li>
94
+ ) : (
95
+ <li>
96
+ <a
97
+ href={pageUrl(p)}
98
+ class:list={[
99
+ "btn page-number text-center",
100
+ p === currentPage ? "outline" : "ghost",
101
+ ]}
102
+ aria-current={p === currentPage ? "page" : undefined}
103
+ >
104
+ {p}
105
+ </a>
106
+ </li>
107
+ ),
108
+ )}
109
+
110
+ <li>
111
+ {hasNext ? (
112
+ <a href={pageUrl(currentPage + 1)} class="btn ghost">
113
+ Suivant
114
+ <Icon icon="lucide:chevron-right" />
115
+ </a>
116
+ ) : (
117
+ <span class="btn ghost" aria-disabled="true">
118
+ Suivant
119
+ <Icon icon="lucide:chevron-right" />
120
+ </span>
121
+ )}
122
+ </li>
123
+ </ul>
124
+ </nav>
125
+ )
126
+ }
127
+
128
+ <style>
129
+ .stnd-pagination {
130
+ display: flex;
131
+ justify-content: center;
132
+ width: 100%;
133
+ }
134
+ ul {
135
+ display: flex;
136
+ flex-wrap: wrap;
137
+ align-items: center;
138
+ gap: var(--space-d4);
139
+ list-style: none;
140
+ padding: 0;
141
+ margin: 0;
142
+ }
143
+ .page-number {
144
+ justify-content: center;
145
+ }
146
+ .ellipsis {
147
+ pointer-events: none;
148
+ opacity: 0.5;
149
+ }
150
+ [aria-disabled="true"] {
151
+ opacity: 0.4;
152
+ pointer-events: none;
153
+ }
154
+ </style>