@nqmcreative/ui 0.1.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 (135) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +507 -0
  3. package/cli/index.mjs +410 -0
  4. package/dist/actions/anchor.d.ts +29 -0
  5. package/dist/actions/anchor.js +122 -0
  6. package/dist/actions/dismissable.d.ts +44 -0
  7. package/dist/actions/dismissable.js +147 -0
  8. package/dist/components/Accordion.svelte +14 -0
  9. package/dist/components/Accordion.svelte.d.ts +8 -0
  10. package/dist/components/AccordionItem.svelte +46 -0
  11. package/dist/components/AccordionItem.svelte.d.ts +14 -0
  12. package/dist/components/Alert.svelte +88 -0
  13. package/dist/components/Alert.svelte.d.ts +18 -0
  14. package/dist/components/Avatar.svelte +58 -0
  15. package/dist/components/Avatar.svelte.d.ts +16 -0
  16. package/dist/components/AvatarGroup.svelte +17 -0
  17. package/dist/components/AvatarGroup.svelte.d.ts +8 -0
  18. package/dist/components/Badge.svelte +53 -0
  19. package/dist/components/Badge.svelte.d.ts +18 -0
  20. package/dist/components/Breadcrumb.svelte +46 -0
  21. package/dist/components/Breadcrumb.svelte.d.ts +14 -0
  22. package/dist/components/Button.svelte +94 -0
  23. package/dist/components/Button.svelte.d.ts +21 -0
  24. package/dist/components/Calendar.svelte +206 -0
  25. package/dist/components/Calendar.svelte.d.ts +22 -0
  26. package/dist/components/Card.svelte +76 -0
  27. package/dist/components/Card.svelte.d.ts +21 -0
  28. package/dist/components/Checkbox.svelte +69 -0
  29. package/dist/components/Checkbox.svelte.d.ts +15 -0
  30. package/dist/components/CheckboxGroup.svelte +109 -0
  31. package/dist/components/CheckboxGroup.svelte.d.ts +30 -0
  32. package/dist/components/Combobox.svelte +268 -0
  33. package/dist/components/Combobox.svelte.d.ts +29 -0
  34. package/dist/components/CommandPalette.svelte +226 -0
  35. package/dist/components/CommandPalette.svelte.d.ts +32 -0
  36. package/dist/components/ConfirmDialog.svelte +97 -0
  37. package/dist/components/ConfirmDialog.svelte.d.ts +22 -0
  38. package/dist/components/ContextMenu.svelte +90 -0
  39. package/dist/components/ContextMenu.svelte.d.ts +15 -0
  40. package/dist/components/DatePicker.svelte +186 -0
  41. package/dist/components/DatePicker.svelte.d.ts +23 -0
  42. package/dist/components/Divider.svelte +30 -0
  43. package/dist/components/Divider.svelte.d.ts +10 -0
  44. package/dist/components/Drawer.svelte +161 -0
  45. package/dist/components/Drawer.svelte.d.ts +20 -0
  46. package/dist/components/Dropdown.svelte +112 -0
  47. package/dist/components/Dropdown.svelte.d.ts +23 -0
  48. package/dist/components/Dropzone.svelte +226 -0
  49. package/dist/components/Dropzone.svelte.d.ts +32 -0
  50. package/dist/components/EmptyState.svelte +57 -0
  51. package/dist/components/EmptyState.svelte.d.ts +18 -0
  52. package/dist/components/Field.svelte +42 -0
  53. package/dist/components/Field.svelte.d.ts +16 -0
  54. package/dist/components/Footer.svelte +93 -0
  55. package/dist/components/Footer.svelte.d.ts +25 -0
  56. package/dist/components/Input.svelte +60 -0
  57. package/dist/components/Input.svelte.d.ts +18 -0
  58. package/dist/components/InputGroup.svelte +19 -0
  59. package/dist/components/InputGroup.svelte.d.ts +10 -0
  60. package/dist/components/Kbd.svelte +17 -0
  61. package/dist/components/Kbd.svelte.d.ts +8 -0
  62. package/dist/components/Label.svelte +16 -0
  63. package/dist/components/Label.svelte.d.ts +9 -0
  64. package/dist/components/Link.svelte +44 -0
  65. package/dist/components/Link.svelte.d.ts +16 -0
  66. package/dist/components/LocaleProvider.svelte +24 -0
  67. package/dist/components/LocaleProvider.svelte.d.ts +10 -0
  68. package/dist/components/MenuItem.svelte +58 -0
  69. package/dist/components/MenuItem.svelte.d.ts +18 -0
  70. package/dist/components/MenuSeparator.svelte +22 -0
  71. package/dist/components/MenuSeparator.svelte.d.ts +8 -0
  72. package/dist/components/Modal.svelte +114 -0
  73. package/dist/components/Modal.svelte.d.ts +21 -0
  74. package/dist/components/MultiSelect.svelte +292 -0
  75. package/dist/components/MultiSelect.svelte.d.ts +31 -0
  76. package/dist/components/Navbar.svelte +190 -0
  77. package/dist/components/Navbar.svelte.d.ts +33 -0
  78. package/dist/components/NumberInput.svelte +100 -0
  79. package/dist/components/NumberInput.svelte.d.ts +15 -0
  80. package/dist/components/Pagination.svelte +90 -0
  81. package/dist/components/Pagination.svelte.d.ts +15 -0
  82. package/dist/components/PasswordInput.svelte +104 -0
  83. package/dist/components/PasswordInput.svelte.d.ts +12 -0
  84. package/dist/components/Popover.svelte +114 -0
  85. package/dist/components/Popover.svelte.d.ts +19 -0
  86. package/dist/components/Progress.svelte +56 -0
  87. package/dist/components/Progress.svelte.d.ts +17 -0
  88. package/dist/components/Radio.svelte +55 -0
  89. package/dist/components/Radio.svelte.d.ts +15 -0
  90. package/dist/components/RadioGroup.svelte +93 -0
  91. package/dist/components/RadioGroup.svelte.d.ts +27 -0
  92. package/dist/components/SegmentedControl.svelte +88 -0
  93. package/dist/components/SegmentedControl.svelte.d.ts +25 -0
  94. package/dist/components/Select.svelte +61 -0
  95. package/dist/components/Select.svelte.d.ts +20 -0
  96. package/dist/components/Sidebar.svelte +206 -0
  97. package/dist/components/Sidebar.svelte.d.ts +36 -0
  98. package/dist/components/Skeleton.svelte +46 -0
  99. package/dist/components/Skeleton.svelte.d.ts +13 -0
  100. package/dist/components/Slider.svelte +79 -0
  101. package/dist/components/Slider.svelte.d.ts +19 -0
  102. package/dist/components/Spinner.svelte +34 -0
  103. package/dist/components/Spinner.svelte.d.ts +13 -0
  104. package/dist/components/Stat.svelte +49 -0
  105. package/dist/components/Stat.svelte.d.ts +16 -0
  106. package/dist/components/Steps.svelte +136 -0
  107. package/dist/components/Steps.svelte.d.ts +24 -0
  108. package/dist/components/Switch.svelte +64 -0
  109. package/dist/components/Switch.svelte.d.ts +14 -0
  110. package/dist/components/Table.svelte +236 -0
  111. package/dist/components/Table.svelte.d.ts +70 -0
  112. package/dist/components/Tabs.svelte +91 -0
  113. package/dist/components/Tabs.svelte.d.ts +23 -0
  114. package/dist/components/Textarea.svelte +54 -0
  115. package/dist/components/Textarea.svelte.d.ts +12 -0
  116. package/dist/components/ThemeToggle.svelte +133 -0
  117. package/dist/components/ThemeToggle.svelte.d.ts +20 -0
  118. package/dist/components/Toaster.svelte +110 -0
  119. package/dist/components/Toaster.svelte.d.ts +8 -0
  120. package/dist/components/Tooltip.svelte +33 -0
  121. package/dist/components/Tooltip.svelte.d.ts +12 -0
  122. package/dist/date.d.ts +35 -0
  123. package/dist/date.js +108 -0
  124. package/dist/fonts.css +193 -0
  125. package/dist/index.d.ts +105 -0
  126. package/dist/index.js +77 -0
  127. package/dist/locale.svelte.d.ts +93 -0
  128. package/dist/locale.svelte.js +153 -0
  129. package/dist/theme.css +210 -0
  130. package/dist/toast.svelte.d.ts +47 -0
  131. package/dist/toast.svelte.js +60 -0
  132. package/dist/tones.d.ts +48 -0
  133. package/dist/tones.js +209 -0
  134. package/package.json +349 -0
  135. package/registry.json +669 -0
@@ -0,0 +1,190 @@
1
+ <script module lang="ts">
2
+ export interface NavItem {
3
+ label: string;
4
+ href?: string;
5
+ /** Marks the current page — also sets `aria-current`. */
6
+ active?: boolean;
7
+ disabled?: boolean;
8
+ /** Renders a dropdown instead of a plain link. */
9
+ items?: NavItem[];
10
+ /** Small count or tag after the label. */
11
+ badge?: string | number;
12
+ }
13
+ </script>
14
+
15
+ <script lang="ts">
16
+ import type { Snippet } from 'svelte';
17
+ import { useLocale } from '../locale.svelte.js';
18
+ import { focusRing, toneRing, toneText, type Tone } from '../tones.js';
19
+ import Drawer from './Drawer.svelte';
20
+ import Dropdown from './Dropdown.svelte';
21
+ import MenuItem from './MenuItem.svelte';
22
+
23
+ interface Props {
24
+ items?: NavItem[];
25
+ /** Logo or wordmark on the left. */
26
+ brand?: Snippet;
27
+ /** Buttons on the right — sign in, theme toggle, avatar. */
28
+ actions?: Snippet;
29
+ /** Extra content inside the mobile drawer, under the links. */
30
+ mobileExtra?: Snippet;
31
+ tone?: Tone;
32
+ /** Sticks to the top of the viewport. */
33
+ sticky?: boolean;
34
+ /** Hairline along the bottom edge. Default true. */
35
+ bordered?: boolean;
36
+ /** Bindable — the mobile drawer's state. */
37
+ menuOpen?: boolean;
38
+ class?: string;
39
+ }
40
+
41
+ let {
42
+ items = [],
43
+ brand,
44
+ actions,
45
+ mobileExtra,
46
+ tone = 'brand',
47
+ sticky = false,
48
+ bordered = true,
49
+ menuOpen = $bindable(false),
50
+ class: className = ''
51
+ }: Props = $props();
52
+
53
+ const t = useLocale();
54
+
55
+ const link =
56
+ 'inline-flex items-center gap-2 font-sans text-[15px] transition-colors duration-150 ease-brand-out';
57
+ </script>
58
+
59
+ <header
60
+ class="w-full bg-bg {sticky ? 'sticky top-0 z-40' : ''} {bordered
61
+ ? 'border-b border-hairline'
62
+ : ''} {className}"
63
+ >
64
+ <nav
65
+ aria-label={t.current.navigation}
66
+ class="mx-auto flex h-16 w-full max-w-6xl items-center gap-6 px-6"
67
+ >
68
+ {#if brand}
69
+ <div class="flex shrink-0 items-center">{@render brand()}</div>
70
+ {/if}
71
+
72
+ <!-- desktop links -->
73
+ <ul class="hidden flex-1 items-center gap-6 md:flex">
74
+ {#each items as item (item.label)}
75
+ <li>
76
+ {#if item.items?.length}
77
+ <Dropdown label={item.label}>
78
+ {#snippet trigger()}
79
+ <button
80
+ type="button"
81
+ class="{link} {focusRing} {toneRing[tone]} {item.active
82
+ ? toneText[tone]
83
+ : 'text-text-secondary hover:text-text'}"
84
+ >
85
+ {item.label}
86
+ <svg class="size-3" viewBox="0 0 16 16" fill="none" aria-hidden="true">
87
+ <path
88
+ d="m4 6 4 4 4-4"
89
+ stroke="currentColor"
90
+ stroke-width="1.8"
91
+ stroke-linecap="square"
92
+ />
93
+ </svg>
94
+ </button>
95
+ {/snippet}
96
+ {#each item.items as child (child.label)}
97
+ <MenuItem href={child.href} disabled={child.disabled} selected={child.active}>
98
+ {child.label}
99
+ </MenuItem>
100
+ {/each}
101
+ </Dropdown>
102
+ {:else}
103
+ <a
104
+ href={item.href}
105
+ aria-current={item.active ? 'page' : undefined}
106
+ class="{link} {focusRing} {toneRing[tone]} {item.active
107
+ ? toneText[tone]
108
+ : 'text-text-secondary hover:text-text'} {item.disabled
109
+ ? 'pointer-events-none opacity-50'
110
+ : ''}"
111
+ >
112
+ {item.label}
113
+ {#if item.badge !== undefined}
114
+ <span class="font-mono text-[11px] text-text-muted">{item.badge}</span>
115
+ {/if}
116
+ </a>
117
+ {/if}
118
+ </li>
119
+ {/each}
120
+ </ul>
121
+
122
+ <div class="ml-auto flex items-center gap-3">
123
+ {#if actions}
124
+ <div class="hidden items-center gap-3 md:flex">{@render actions()}</div>
125
+ {/if}
126
+
127
+ <!-- mobile trigger -->
128
+ <button
129
+ type="button"
130
+ onclick={() => (menuOpen = true)}
131
+ aria-label={t.current.openMenu}
132
+ aria-expanded={menuOpen}
133
+ class="inline-flex size-9 items-center justify-center text-text-secondary transition-colors duration-150 hover:text-text md:hidden {focusRing} {toneRing[
134
+ tone
135
+ ]}"
136
+ >
137
+ <svg class="size-5" viewBox="0 0 20 20" fill="none" aria-hidden="true">
138
+ <path d="M3 6h14M3 10h14M3 14h14" stroke="currentColor" stroke-width="1.5" />
139
+ </svg>
140
+ </button>
141
+ </div>
142
+ </nav>
143
+ </header>
144
+
145
+ <Drawer bind:open={menuOpen} side="right" size="sm" title={t.current.navigation}>
146
+ <ul class="flex flex-col">
147
+ {#each items as item (item.label)}
148
+ <li class="border-b border-hairline last:border-b-0">
149
+ <a
150
+ href={item.href}
151
+ onclick={() => (menuOpen = false)}
152
+ aria-current={item.active ? 'page' : undefined}
153
+ class="flex items-center justify-between gap-3 py-3 font-sans text-[15px] {item.active
154
+ ? toneText[tone]
155
+ : 'text-text-secondary'} {item.disabled ? 'pointer-events-none opacity-50' : ''}"
156
+ >
157
+ {item.label}
158
+ {#if item.badge !== undefined}
159
+ <span class="font-mono text-[11px] text-text-muted">{item.badge}</span>
160
+ {/if}
161
+ </a>
162
+ {#if item.items?.length}
163
+ <ul class="flex flex-col pb-2 pl-4">
164
+ {#each item.items as child (child.label)}
165
+ <li>
166
+ <a
167
+ href={child.href}
168
+ onclick={() => (menuOpen = false)}
169
+ class="block py-2 font-sans text-sm text-text-muted"
170
+ >
171
+ {child.label}
172
+ </a>
173
+ </li>
174
+ {/each}
175
+ </ul>
176
+ {/if}
177
+ </li>
178
+ {/each}
179
+ </ul>
180
+
181
+ {#if actions || mobileExtra}
182
+ <div class="mt-6 flex flex-col gap-3 border-t border-hairline pt-6">
183
+ {#if mobileExtra}
184
+ {@render mobileExtra()}
185
+ {:else if actions}
186
+ {@render actions()}
187
+ {/if}
188
+ </div>
189
+ {/if}
190
+ </Drawer>
@@ -0,0 +1,33 @@
1
+ export interface NavItem {
2
+ label: string;
3
+ href?: string;
4
+ /** Marks the current page — also sets `aria-current`. */
5
+ active?: boolean;
6
+ disabled?: boolean;
7
+ /** Renders a dropdown instead of a plain link. */
8
+ items?: NavItem[];
9
+ /** Small count or tag after the label. */
10
+ badge?: string | number;
11
+ }
12
+ import type { Snippet } from 'svelte';
13
+ import { type Tone } from '../tones.js';
14
+ interface Props {
15
+ items?: NavItem[];
16
+ /** Logo or wordmark on the left. */
17
+ brand?: Snippet;
18
+ /** Buttons on the right — sign in, theme toggle, avatar. */
19
+ actions?: Snippet;
20
+ /** Extra content inside the mobile drawer, under the links. */
21
+ mobileExtra?: Snippet;
22
+ tone?: Tone;
23
+ /** Sticks to the top of the viewport. */
24
+ sticky?: boolean;
25
+ /** Hairline along the bottom edge. Default true. */
26
+ bordered?: boolean;
27
+ /** Bindable — the mobile drawer's state. */
28
+ menuOpen?: boolean;
29
+ class?: string;
30
+ }
31
+ declare const Navbar: import("svelte").Component<Props, {}, "menuOpen">;
32
+ type Navbar = ReturnType<typeof Navbar>;
33
+ export default Navbar;
@@ -0,0 +1,100 @@
1
+ <script lang="ts">
2
+ import type { HTMLInputAttributes } from 'svelte/elements';
3
+ import { toneFocusWithinBorder, type Tone } from '../tones.js';
4
+ import { useLocale } from '../locale.svelte.js';
5
+
6
+ interface Props extends Omit<HTMLInputAttributes, 'size' | 'type' | 'value' | 'min' | 'max'> {
7
+ value?: number;
8
+ min?: number;
9
+ max?: number;
10
+ step?: number;
11
+ tone?: Tone;
12
+ invalid?: boolean;
13
+ /** Short unit rendered inside the field, e.g. `'px'` or `'%'`. */
14
+ unit?: string;
15
+ }
16
+
17
+ let {
18
+ value = $bindable(0),
19
+ min = -Infinity,
20
+ max = Infinity,
21
+ step = 1,
22
+ tone = 'brand',
23
+ invalid = false,
24
+ unit,
25
+ disabled = false,
26
+ class: className = '',
27
+ ...rest
28
+ }: Props = $props();
29
+
30
+ const t = useLocale();
31
+
32
+ const decimals = $derived((String(step).split('.')[1] ?? '').length);
33
+
34
+ function clamp(next: number) {
35
+ if (Number.isNaN(next)) return min === -Infinity ? 0 : min;
36
+ return Number(Math.min(max, Math.max(min, next)).toFixed(decimals));
37
+ }
38
+
39
+ function nudge(direction: 1 | -1) {
40
+ value = clamp(value + direction * step);
41
+ }
42
+
43
+ function onKeydown(event: KeyboardEvent) {
44
+ if (event.key === 'ArrowUp') {
45
+ event.preventDefault();
46
+ nudge(1);
47
+ } else if (event.key === 'ArrowDown') {
48
+ event.preventDefault();
49
+ nudge(-1);
50
+ }
51
+ }
52
+
53
+ const stepper =
54
+ 'flex size-8 shrink-0 items-center justify-center text-text-secondary transition-colors duration-150 hover:bg-bg-inset hover:text-text disabled:pointer-events-none disabled:opacity-30 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current';
55
+ </script>
56
+
57
+ <span
58
+ class="inline-flex w-full items-center border bg-bg pr-1 pl-4 transition-colors duration-150 ease-brand-out
59
+ {invalid ? 'border-danger' : `border-hairline ${toneFocusWithinBorder[tone]}`}
60
+ {disabled ? 'pointer-events-none opacity-50' : ''} {className}"
61
+ >
62
+ <input
63
+ type="number"
64
+ {min}
65
+ {max}
66
+ {step}
67
+ {disabled}
68
+ {value}
69
+ oninput={(event) => (value = clamp(event.currentTarget.valueAsNumber))}
70
+ onkeydown={onKeydown}
71
+ aria-invalid={invalid ? 'true' : undefined}
72
+ class="h-11 w-full min-w-0 [appearance:textfield] bg-transparent font-sans text-[15px] text-text tabular-nums placeholder:text-text-muted focus:outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
73
+ {...rest}
74
+ />
75
+ {#if unit}
76
+ <span class="shrink-0 px-2 font-mono text-xs text-text-muted">{unit}</span>
77
+ {/if}
78
+ <button
79
+ type="button"
80
+ class={stepper}
81
+ onclick={() => nudge(-1)}
82
+ disabled={disabled || value <= min}
83
+ aria-label={t.current.decrease}
84
+ >
85
+ <svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
86
+ <path d="M2.5 7h9" stroke="currentColor" stroke-width="1.5" />
87
+ </svg>
88
+ </button>
89
+ <button
90
+ type="button"
91
+ class={stepper}
92
+ onclick={() => nudge(1)}
93
+ disabled={disabled || value >= max}
94
+ aria-label={t.current.increase}
95
+ >
96
+ <svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
97
+ <path d="M7 2.5v9M2.5 7h9" stroke="currentColor" stroke-width="1.5" />
98
+ </svg>
99
+ </button>
100
+ </span>
@@ -0,0 +1,15 @@
1
+ import type { HTMLInputAttributes } from 'svelte/elements';
2
+ import { type Tone } from '../tones.js';
3
+ interface Props extends Omit<HTMLInputAttributes, 'size' | 'type' | 'value' | 'min' | 'max'> {
4
+ value?: number;
5
+ min?: number;
6
+ max?: number;
7
+ step?: number;
8
+ tone?: Tone;
9
+ invalid?: boolean;
10
+ /** Short unit rendered inside the field, e.g. `'px'` or `'%'`. */
11
+ unit?: string;
12
+ }
13
+ declare const NumberInput: import("svelte").Component<Props, {}, "value">;
14
+ type NumberInput = ReturnType<typeof NumberInput>;
15
+ export default NumberInput;
@@ -0,0 +1,90 @@
1
+ <script lang="ts">
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ import { focusRing, toneRing, toneSolid, type Tone } from '../tones.js';
4
+ import { useLocale } from '../locale.svelte.js';
5
+
6
+ interface Props extends Omit<HTMLAttributes<HTMLElement>, 'onchange'> {
7
+ /** Bindable, 1-based. */
8
+ page?: number;
9
+ /** Total number of pages. */
10
+ total: number;
11
+ /** Pages shown either side of the current one before collapsing to `…`. */
12
+ siblings?: number;
13
+ tone?: Tone;
14
+ onchange?: (page: number) => void;
15
+ }
16
+
17
+ let {
18
+ page = $bindable(1),
19
+ total,
20
+ siblings = 1,
21
+ tone = 'brand',
22
+ class: className = '',
23
+ onchange,
24
+ ...rest
25
+ }: Props = $props();
26
+
27
+ const t = useLocale();
28
+
29
+ const pages = $derived.by<(number | '…')[]>(() => {
30
+ if (total <= 5 + siblings * 2) {
31
+ return Array.from({ length: total }, (_, i) => i + 1);
32
+ }
33
+ const start = Math.max(2, page - siblings);
34
+ const end = Math.min(total - 1, page + siblings);
35
+ const out: (number | '…')[] = [1];
36
+ if (start > 2) out.push('…');
37
+ for (let i = start; i <= end; i++) out.push(i);
38
+ if (end < total - 1) out.push('…');
39
+ out.push(total);
40
+ return out;
41
+ });
42
+
43
+ function go(next: number) {
44
+ const clamped = Math.min(total, Math.max(1, next));
45
+ if (clamped === page) return;
46
+ page = clamped;
47
+ onchange?.(clamped);
48
+ }
49
+
50
+ const cell = `inline-flex h-9 min-w-9 items-center justify-center px-2 font-mono text-[13px] transition-colors duration-150 ease-brand-out disabled:opacity-40 disabled:pointer-events-none ${focusRing}`;
51
+ </script>
52
+
53
+ <nav aria-label={t.current.pagination} class="flex items-center gap-1 {className}" {...rest}>
54
+ <button
55
+ type="button"
56
+ class="{cell} {toneRing[tone]} text-text-secondary hover:bg-bg-inset"
57
+ disabled={page <= 1}
58
+ aria-label={t.current.previousPage}
59
+ onclick={() => go(page - 1)}
60
+ >
61
+
62
+ </button>
63
+
64
+ {#each pages as item, i (`${item}-${i}`)}
65
+ {#if item === '…'}
66
+ <span class="{cell} text-text-muted">…</span>
67
+ {:else}
68
+ <button
69
+ type="button"
70
+ aria-current={item === page ? 'page' : undefined}
71
+ class="{cell} {toneRing[tone]} {item === page
72
+ ? toneSolid[tone]
73
+ : 'text-text-secondary hover:bg-bg-inset'}"
74
+ onclick={() => go(item)}
75
+ >
76
+ {item}
77
+ </button>
78
+ {/if}
79
+ {/each}
80
+
81
+ <button
82
+ type="button"
83
+ class="{cell} {toneRing[tone]} text-text-secondary hover:bg-bg-inset"
84
+ disabled={page >= total}
85
+ aria-label={t.current.nextPage}
86
+ onclick={() => go(page + 1)}
87
+ >
88
+
89
+ </button>
90
+ </nav>
@@ -0,0 +1,15 @@
1
+ import type { HTMLAttributes } from 'svelte/elements';
2
+ import { type Tone } from '../tones.js';
3
+ interface Props extends Omit<HTMLAttributes<HTMLElement>, 'onchange'> {
4
+ /** Bindable, 1-based. */
5
+ page?: number;
6
+ /** Total number of pages. */
7
+ total: number;
8
+ /** Pages shown either side of the current one before collapsing to `…`. */
9
+ siblings?: number;
10
+ tone?: Tone;
11
+ onchange?: (page: number) => void;
12
+ }
13
+ declare const Pagination: import("svelte").Component<Props, {}, "page">;
14
+ type Pagination = ReturnType<typeof Pagination>;
15
+ export default Pagination;
@@ -0,0 +1,104 @@
1
+ <script lang="ts">
2
+ import type { HTMLInputAttributes } from 'svelte/elements';
3
+ import { toneFocusWithinBorder, type Tone } from '../tones.js';
4
+ import { useLocale } from '../locale.svelte.js';
5
+
6
+ interface Props extends Omit<HTMLInputAttributes, 'size' | 'type'> {
7
+ value?: string;
8
+ tone?: Tone;
9
+ invalid?: boolean;
10
+ /** Draws a four-segment strength meter under the field. */
11
+ strength?: boolean;
12
+ }
13
+
14
+ let {
15
+ value = $bindable(''),
16
+ tone = 'brand',
17
+ invalid = false,
18
+ strength = false,
19
+ disabled = false,
20
+ class: className = '',
21
+ ...rest
22
+ }: Props = $props();
23
+
24
+ const t = useLocale();
25
+
26
+ let visible = $state(false);
27
+
28
+ /** Deliberately simple: length plus character variety, four buckets. */
29
+ const score = $derived.by(() => {
30
+ if (!value) return 0;
31
+ let points = 0;
32
+ if (value.length >= 8) points++;
33
+ if (value.length >= 12) points++;
34
+ if (/[a-z]/.test(value) && /[A-Z]/.test(value)) points++;
35
+ if (/\d/.test(value)) points++;
36
+ if (/[^\w\s]/.test(value)) points++;
37
+ return Math.min(4, points);
38
+ });
39
+
40
+ const bars = ['bg-danger', 'bg-danger', 'bg-warning', 'bg-info', 'bg-success'];
41
+ const texts = ['text-danger', 'text-danger', 'text-warning', 'text-info', 'text-success'];
42
+ </script>
43
+
44
+ <div class="flex w-full flex-col gap-2">
45
+ <span
46
+ class="inline-flex w-full items-center gap-2 border bg-bg pr-2 pl-4 transition-colors duration-150 ease-brand-out
47
+ {invalid ? 'border-danger' : `border-hairline ${toneFocusWithinBorder[tone]}`}
48
+ {disabled ? 'pointer-events-none opacity-50' : ''} {className}"
49
+ >
50
+ <input
51
+ bind:value
52
+ {disabled}
53
+ type={visible ? 'text' : 'password'}
54
+ aria-invalid={invalid ? 'true' : undefined}
55
+ class="h-11 w-full min-w-0 bg-transparent font-sans text-[15px] text-text placeholder:text-text-muted focus:outline-none"
56
+ {...rest}
57
+ />
58
+ <button
59
+ type="button"
60
+ onclick={() => (visible = !visible)}
61
+ aria-label={visible ? t.current.hidePassword : t.current.showPassword}
62
+ aria-pressed={visible}
63
+ class="shrink-0 p-2 text-text-muted transition-colors duration-150 hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
64
+ >
65
+ {#if visible}
66
+ <svg class="size-4" viewBox="0 0 20 20" fill="none" aria-hidden="true">
67
+ <path
68
+ d="M2.5 10S5.5 4.5 10 4.5 17.5 10 17.5 10 14.5 15.5 10 15.5 2.5 10 2.5 10Z"
69
+ stroke="currentColor"
70
+ stroke-width="1.4"
71
+ />
72
+ <circle cx="10" cy="10" r="2.4" stroke="currentColor" stroke-width="1.4" />
73
+ <path d="m3.5 16.5 13-13" stroke="currentColor" stroke-width="1.4" />
74
+ </svg>
75
+ {:else}
76
+ <svg class="size-4" viewBox="0 0 20 20" fill="none" aria-hidden="true">
77
+ <path
78
+ d="M2.5 10S5.5 4.5 10 4.5 17.5 10 17.5 10 14.5 15.5 10 15.5 2.5 10 2.5 10Z"
79
+ stroke="currentColor"
80
+ stroke-width="1.4"
81
+ />
82
+ <circle cx="10" cy="10" r="2.4" stroke="currentColor" stroke-width="1.4" />
83
+ </svg>
84
+ {/if}
85
+ </button>
86
+ </span>
87
+
88
+ {#if strength}
89
+ <div class="flex items-center gap-3">
90
+ <div class="flex flex-1 gap-1" aria-hidden="true">
91
+ {#each [0, 1, 2, 3] as segment (segment)}
92
+ <span
93
+ class="h-1 flex-1 transition-colors duration-200 {segment < score
94
+ ? bars[score]
95
+ : 'bg-bg-inset'}"
96
+ ></span>
97
+ {/each}
98
+ </div>
99
+ <span class="font-mono text-xs {value ? texts[score] : 'text-text-muted'}">
100
+ {value ? t.current.passwordStrength[score] : '—'}
101
+ </span>
102
+ </div>
103
+ {/if}
104
+ </div>
@@ -0,0 +1,12 @@
1
+ import type { HTMLInputAttributes } from 'svelte/elements';
2
+ import { type Tone } from '../tones.js';
3
+ interface Props extends Omit<HTMLInputAttributes, 'size' | 'type'> {
4
+ value?: string;
5
+ tone?: Tone;
6
+ invalid?: boolean;
7
+ /** Draws a four-segment strength meter under the field. */
8
+ strength?: boolean;
9
+ }
10
+ declare const PasswordInput: import("svelte").Component<Props, {}, "value">;
11
+ type PasswordInput = ReturnType<typeof PasswordInput>;
12
+ export default PasswordInput;
@@ -0,0 +1,114 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { anchored, type Placement } from '../actions/anchor.js';
4
+ import { clickOutside, focusTrap, portal } from '../actions/dismissable.js';
5
+
6
+ interface Props {
7
+ trigger: Snippet;
8
+ children: Snippet;
9
+ open?: boolean;
10
+ placement?: Placement;
11
+ offset?: number;
12
+ title?: string;
13
+ /** `'hover'` also opens on focus, for read-only content. */
14
+ on?: 'click' | 'hover';
15
+ /** Keep focus where it is — right for hover popovers and hover cards. */
16
+ trapFocus?: boolean;
17
+ class?: string;
18
+ triggerClass?: string;
19
+ }
20
+
21
+ let {
22
+ trigger,
23
+ children,
24
+ open = $bindable(false),
25
+ placement = 'bottom',
26
+ offset = 8,
27
+ title,
28
+ on = 'click',
29
+ trapFocus = true,
30
+ class: className = '',
31
+ triggerClass = ''
32
+ }: Props = $props();
33
+
34
+ let anchorEl: HTMLElement | null = $state(null);
35
+ let closeTimer: ReturnType<typeof setTimeout> | undefined;
36
+
37
+ function triggerBehaviour(node: HTMLElement) {
38
+ const toggle = () => (open = !open);
39
+ const show = () => {
40
+ clearTimeout(closeTimer);
41
+ open = true;
42
+ };
43
+ const hide = () => {
44
+ closeTimer = setTimeout(() => (open = false), 120);
45
+ };
46
+
47
+ if (on === 'click') {
48
+ node.addEventListener('click', toggle);
49
+ } else {
50
+ node.addEventListener('pointerenter', show);
51
+ node.addEventListener('pointerleave', hide);
52
+ node.addEventListener('focusin', show);
53
+ node.addEventListener('focusout', hide);
54
+ }
55
+
56
+ return {
57
+ destroy() {
58
+ clearTimeout(closeTimer);
59
+ node.removeEventListener('click', toggle);
60
+ node.removeEventListener('pointerenter', show);
61
+ node.removeEventListener('pointerleave', hide);
62
+ node.removeEventListener('focusin', show);
63
+ node.removeEventListener('focusout', hide);
64
+ }
65
+ };
66
+ }
67
+
68
+ $effect(() => {
69
+ const control = anchorEl?.querySelector('button, a, [role="button"]');
70
+ if (!control) return;
71
+ control.setAttribute('aria-haspopup', 'dialog');
72
+ control.setAttribute('aria-expanded', String(open));
73
+ });
74
+
75
+ function onKeydown(event: KeyboardEvent) {
76
+ if (event.key !== 'Escape') return;
77
+ event.preventDefault();
78
+ event.stopPropagation();
79
+ open = false;
80
+ }
81
+ </script>
82
+
83
+ <span bind:this={anchorEl} use:triggerBehaviour class="inline-flex {triggerClass}">
84
+ {@render trigger()}
85
+ </span>
86
+
87
+ {#if open}
88
+ <div
89
+ use:portal
90
+ use:anchored={{ anchor: anchorEl, placement, offset }}
91
+ use:clickOutside={{
92
+ onoutside: () => (open = false),
93
+ ignore: [anchorEl],
94
+ enabled: on === 'click'
95
+ }}
96
+ use:focusTrap={{ enabled: trapFocus, autofocus: trapFocus, restore: trapFocus }}
97
+ onkeydown={onKeydown}
98
+ onpointerenter={() => clearTimeout(closeTimer)}
99
+ onpointerleave={() => {
100
+ if (on === 'hover') closeTimer = setTimeout(() => (open = false), 120);
101
+ }}
102
+ role="dialog"
103
+ aria-label={title}
104
+ tabindex="-1"
105
+ class="z-50 flex max-w-xs flex-col gap-2 border border-hairline bg-bg p-4 font-sans {className}"
106
+ >
107
+ {#if title}
108
+ <p class="font-heading text-sm font-medium text-text">{title}</p>
109
+ {/if}
110
+ <div class="text-sm leading-relaxed text-text-secondary">
111
+ {@render children()}
112
+ </div>
113
+ </div>
114
+ {/if}