@aiaiai-pt/design-system 0.3.7 → 0.4.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,142 @@
1
+ <!--
2
+ @component Breadcrumb
3
+
4
+ Navigation breadcrumb trail. Items with `href` are links; the last item
5
+ (current page, no href) is rendered as plain bold text.
6
+
7
+ @example
8
+ <Breadcrumb items={[
9
+ { label: 'Home', href: '/' },
10
+ { label: 'Settings', href: '/settings' },
11
+ { label: 'Profile' },
12
+ ]} />
13
+
14
+ @example With truncation
15
+ <Breadcrumb
16
+ items={deepPath}
17
+ max_items={4}
18
+ />
19
+ -->
20
+ <script>
21
+ /**
22
+ * @typedef {{ label: string, href?: string }} BreadcrumbItem
23
+ */
24
+
25
+ let {
26
+ /** @type {BreadcrumbItem[]} */
27
+ items = [],
28
+ /** @type {number | undefined} */
29
+ max_items = undefined,
30
+ /** @type {string} */
31
+ class: className = '',
32
+ ...rest
33
+ } = $props();
34
+
35
+ /**
36
+ * Truncate items when max_items is set.
37
+ * Keeps first item + ellipsis + last (max_items - 2) items.
38
+ * Minimum effective max_items is 3 (first + ellipsis + last).
39
+ * @type {(BreadcrumbItem | null)[]}
40
+ */
41
+ const visible_items = $derived(
42
+ !max_items || items.length <= max_items
43
+ ? items
44
+ : (() => {
45
+ const keep_end = Math.max(1, max_items - 2);
46
+ /** @type {(BreadcrumbItem | null)[]} */
47
+ const result = [items[0], null];
48
+ for (let i = items.length - keep_end; i < items.length; i++) {
49
+ result.push(items[i]);
50
+ }
51
+ return result;
52
+ })()
53
+ );
54
+ </script>
55
+
56
+ <nav class="breadcrumb {className}" aria-label="Breadcrumb" {...rest}>
57
+ <ol class="breadcrumb-list">
58
+ {#each visible_items as item, idx}
59
+ {#if item === null}
60
+ <li class="breadcrumb-item" aria-hidden="true">
61
+ <span class="breadcrumb-separator" aria-hidden="true">/</span>
62
+ <span class="breadcrumb-ellipsis">…</span>
63
+ </li>
64
+ {:else}
65
+ {#if idx > 0}
66
+ <li class="breadcrumb-sep-item" aria-hidden="true">
67
+ <span class="breadcrumb-separator">/</span>
68
+ </li>
69
+ {/if}
70
+ <li class="breadcrumb-item">
71
+ {#if item.href}
72
+ <a href={item.href} class="breadcrumb-link">{item.label}</a>
73
+ {:else}
74
+ <span class="breadcrumb-current" aria-current="page">{item.label}</span>
75
+ {/if}
76
+ </li>
77
+ {/if}
78
+ {/each}
79
+ </ol>
80
+ </nav>
81
+
82
+ <style>
83
+ .breadcrumb {
84
+ /* no outer constraints — caller controls width */
85
+ }
86
+
87
+ .breadcrumb-list {
88
+ display: flex;
89
+ align-items: center;
90
+ flex-wrap: wrap;
91
+ gap: 0;
92
+ list-style: none;
93
+ margin: 0;
94
+ padding: 0;
95
+ font-family: var(--type-body-sm-font);
96
+ font-size: var(--type-body-sm-size);
97
+ }
98
+
99
+ .breadcrumb-item {
100
+ display: inline-flex;
101
+ align-items: center;
102
+ }
103
+
104
+ .breadcrumb-sep-item {
105
+ display: inline-flex;
106
+ align-items: center;
107
+ }
108
+
109
+ .breadcrumb-separator {
110
+ margin: 0 var(--space-xs);
111
+ color: var(--color-text-muted);
112
+ user-select: none;
113
+ font-size: var(--type-body-sm-size);
114
+ }
115
+
116
+ .breadcrumb-link {
117
+ color: var(--color-text-secondary);
118
+ text-decoration: none;
119
+ transition: color var(--duration-instant) var(--easing-default);
120
+ }
121
+
122
+ .breadcrumb-link:hover {
123
+ color: var(--color-text);
124
+ text-decoration: underline;
125
+ }
126
+
127
+ .breadcrumb-link:focus-visible {
128
+ outline: var(--focus-ring-width) solid var(--focus-ring-color);
129
+ outline-offset: var(--focus-ring-offset);
130
+ border-radius: var(--radius-sm);
131
+ }
132
+
133
+ .breadcrumb-current {
134
+ color: var(--color-text);
135
+ font-weight: var(--raw-font-weight-semibold, 600);
136
+ }
137
+
138
+ .breadcrumb-ellipsis {
139
+ color: var(--color-text-muted);
140
+ user-select: none;
141
+ }
142
+ </style>
@@ -0,0 +1,38 @@
1
+ export default Breadcrumb;
2
+ type Breadcrumb = {
3
+ $on?(type: string, callback: (e: any) => void): () => void;
4
+ $set?(props: Partial<$$ComponentProps>): void;
5
+ };
6
+ /**
7
+ * Breadcrumb
8
+ *
9
+ * Navigation breadcrumb trail. Items with `href` are links; the last item
10
+ * (current page, no href) is rendered as plain bold text.
11
+ *
12
+ * @example
13
+ * <Breadcrumb items={[
14
+ * { label: 'Home', href: '/' },
15
+ * { label: 'Settings', href: '/settings' },
16
+ * { label: 'Profile' },
17
+ * ]} />
18
+ *
19
+ * @example With truncation
20
+ * <Breadcrumb
21
+ * items={deepPath}
22
+ * max_items={4}
23
+ * />
24
+ */
25
+ declare const Breadcrumb: import("svelte").Component<
26
+ {
27
+ items?: any[];
28
+ max_items?: any;
29
+ class?: string;
30
+ } & Record<string, any>,
31
+ {},
32
+ ""
33
+ >;
34
+ type $$ComponentProps = {
35
+ items?: any[];
36
+ max_items?: any;
37
+ class?: string;
38
+ } & Record<string, any>;
@@ -0,0 +1,420 @@
1
+ <!--
2
+ @component DataTable
3
+
4
+ Data table with sortable columns, row selection, loading and empty states.
5
+ Composes Skeleton and EmptyState primitives. Consumes --table-* tokens from
6
+ components.css, falling back to semantic tokens.
7
+
8
+ @example Basic
9
+ <DataTable
10
+ columns={[
11
+ { key: 'name', label: 'NAME' },
12
+ { key: 'status', label: 'STATUS', width: '120px' },
13
+ ]}
14
+ rows={items}
15
+ on_sort={(key, dir) => sort(key, dir)}
16
+ on_row_click={(row) => goto(`/items/${row.id}`)}
17
+ />
18
+
19
+ @example Selectable
20
+ <DataTable
21
+ {columns}
22
+ {rows}
23
+ selectable
24
+ bind:selected_rows={selected}
25
+ on_select={(sel) => console.log(sel)}
26
+ />
27
+
28
+ @example Loading
29
+ <DataTable {columns} rows={[]} loading />
30
+ -->
31
+ <script>
32
+ /**
33
+ * @typedef {{ key: string, label: string, width?: string, sortable?: boolean, render?: (value: unknown, row: Record<string, unknown>) => string }} ColumnDef
34
+ */
35
+
36
+ import Skeleton from './Skeleton.svelte';
37
+ import EmptyState from './EmptyState.svelte';
38
+
39
+ let {
40
+ /** @type {ColumnDef[]} */
41
+ columns = [],
42
+ /** @type {Record<string, unknown>[]} */
43
+ rows = [],
44
+ /** @type {boolean} */
45
+ loading = false,
46
+ /** @type {boolean} */
47
+ sortable = true,
48
+ /** @type {string | undefined} */
49
+ sort_key = undefined,
50
+ /** @type {'asc' | 'desc'} */
51
+ sort_direction = 'asc',
52
+ /** @type {boolean} */
53
+ selectable = false,
54
+ /** @type {Set<string>} */
55
+ selected_rows = $bindable(new Set()),
56
+ /** @type {string} */
57
+ row_key = 'id',
58
+ /** @type {string} */
59
+ empty_heading = 'No data',
60
+ /** @type {string} */
61
+ empty_body = 'No items to display',
62
+ /** @type {((key: string, direction: 'asc' | 'desc') => void) | undefined} */
63
+ on_sort = undefined,
64
+ /** @type {((selected: Set<string>) => void) | undefined} */
65
+ on_select = undefined,
66
+ /** @type {((row: Record<string, unknown>) => void) | undefined} */
67
+ on_row_click = undefined,
68
+ /** @type {import('svelte').Snippet | undefined} */
69
+ children = undefined,
70
+ /** @type {string} */
71
+ class: className = '',
72
+ ...rest
73
+ } = $props();
74
+
75
+ const SKELETON_ROWS = 5;
76
+
77
+ const all_selected = $derived(
78
+ rows.length > 0 &&
79
+ rows.every((row) => selected_rows.has(String(row[row_key])))
80
+ );
81
+
82
+ const some_selected = $derived(
83
+ rows.some((row) => selected_rows.has(String(row[row_key]))) && !all_selected
84
+ );
85
+
86
+ /**
87
+ * @param {string} key
88
+ */
89
+ function handle_header_click(key) {
90
+ const col = columns.find((c) => c.key === key);
91
+ const col_sortable = col?.sortable ?? sortable;
92
+ if (!col_sortable || !on_sort) return;
93
+
94
+ const next_dir = sort_key === key && sort_direction === 'asc' ? 'desc' : 'asc';
95
+ on_sort(key, next_dir);
96
+ }
97
+
98
+ /** @param {string} row_id */
99
+ function handle_row_check(row_id) {
100
+ const next = new Set(selected_rows);
101
+ if (next.has(row_id)) {
102
+ next.delete(row_id);
103
+ } else {
104
+ next.add(row_id);
105
+ }
106
+ selected_rows = next;
107
+ on_select?.(next);
108
+ }
109
+
110
+ function handle_select_all() {
111
+ let next;
112
+ if (all_selected) {
113
+ next = new Set(/** @type {Set<string>} */ (selected_rows));
114
+ for (const row of rows) {
115
+ next.delete(String(row[row_key]));
116
+ }
117
+ } else {
118
+ next = new Set(/** @type {Set<string>} */ (selected_rows));
119
+ for (const row of rows) {
120
+ next.add(String(row[row_key]));
121
+ }
122
+ }
123
+ selected_rows = next;
124
+ on_select?.(next);
125
+ }
126
+
127
+ /**
128
+ * @param {ColumnDef} col
129
+ * @param {unknown} value
130
+ * @param {Record<string, unknown>} row
131
+ */
132
+ function render_cell(col, value, row) {
133
+ if (col.render) return col.render(value, row);
134
+ if (value === null || value === undefined) return '';
135
+ return String(value);
136
+ }
137
+
138
+ /**
139
+ * @param {string} key
140
+ */
141
+ function is_col_sortable(key) {
142
+ const col = columns.find((c) => c.key === key);
143
+ return col?.sortable ?? sortable;
144
+ }
145
+ </script>
146
+
147
+ <div class="table-wrap {className}" {...rest}>
148
+ {#if children}
149
+ <div class="table-toolbar">
150
+ {@render children()}
151
+ </div>
152
+ {/if}
153
+
154
+ <div class="table-scroll">
155
+ <table class="table">
156
+ <thead class="table-head">
157
+ <tr>
158
+ {#if selectable}
159
+ <th class="table-th table-th-check" scope="col">
160
+ <input
161
+ type="checkbox"
162
+ class="table-checkbox"
163
+ checked={all_selected}
164
+ indeterminate={some_selected}
165
+ disabled={loading}
166
+ aria-label="Select all rows"
167
+ onchange={handle_select_all}
168
+ />
169
+ </th>
170
+ {/if}
171
+ {#each columns as col}
172
+ {@const col_sort = is_col_sortable(col.key)}
173
+ <th
174
+ class="table-th"
175
+ class:table-th-sortable={col_sort && !!on_sort}
176
+ scope="col"
177
+ style:width={col.width ?? undefined}
178
+ onclick={col_sort && on_sort ? () => handle_header_click(col.key) : undefined}
179
+ aria-sort={!col_sort ? undefined : sort_key === col.key ? (sort_direction === 'asc' ? 'ascending' : 'descending') : 'none'}
180
+ >
181
+ <span class="table-th-inner">
182
+ {col.label}
183
+ {#if col_sort && on_sort}
184
+ <span class="table-sort-icon" aria-hidden="true">
185
+ {#if sort_key === col.key}
186
+ {#if sort_direction === 'asc'}
187
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="none">
188
+ <path d="M5 2L9 8H1L5 2Z" fill="currentColor"/>
189
+ </svg>
190
+ {:else}
191
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="none">
192
+ <path d="M5 8L1 2H9L5 8Z" fill="currentColor"/>
193
+ </svg>
194
+ {/if}
195
+ {:else}
196
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="none" style="opacity: 0.3">
197
+ <path d="M5 2L9 5H1L5 2Z" fill="currentColor"/>
198
+ <path d="M5 8L1 5H9L5 8Z" fill="currentColor"/>
199
+ </svg>
200
+ {/if}
201
+ </span>
202
+ {/if}
203
+ </span>
204
+ </th>
205
+ {/each}
206
+ </tr>
207
+ </thead>
208
+
209
+ <tbody class="table-body">
210
+ {#if loading}
211
+ {#each { length: SKELETON_ROWS } as _, i}
212
+ <tr class="table-row" aria-hidden="true">
213
+ {#if selectable}
214
+ <td class="table-td table-td-check">
215
+ <Skeleton width="16px" height="16px" radius="var(--radius-sm)" />
216
+ </td>
217
+ {/if}
218
+ {#each columns as col}
219
+ <td class="table-td">
220
+ <Skeleton width={i % 3 === 0 ? '60%' : i % 3 === 1 ? '80%' : '70%'} height="14px" />
221
+ </td>
222
+ {/each}
223
+ </tr>
224
+ {/each}
225
+ {:else if rows.length === 0}
226
+ <tr>
227
+ <td colspan={selectable ? columns.length + 1 : columns.length} class="table-td-empty">
228
+ <EmptyState heading={empty_heading} body={empty_body} />
229
+ </td>
230
+ </tr>
231
+ {:else}
232
+ {#each rows as row, row_index}
233
+ {@const row_id = String(row[row_key])}
234
+ {@const is_selected = selected_rows.has(row_id)}
235
+ <tr
236
+ class="table-row"
237
+ class:table-row-even={row_index % 2 === 1}
238
+ class:table-row-selected={is_selected}
239
+ class:table-row-clickable={!!on_row_click}
240
+ onclick={on_row_click ? () => on_row_click(row) : undefined}
241
+ tabindex={on_row_click ? 0 : undefined}
242
+ onkeydown={on_row_click ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); on_row_click(row); } } : undefined}
243
+ >
244
+ {#if selectable}
245
+ <td
246
+ class="table-td table-td-check"
247
+ onclick={(e) => { e.stopPropagation(); handle_row_check(row_id); }}
248
+ >
249
+ <input
250
+ type="checkbox"
251
+ class="table-checkbox"
252
+ checked={is_selected}
253
+ aria-label="Select row"
254
+ onchange={() => handle_row_check(row_id)}
255
+ onclick={(e) => e.stopPropagation()}
256
+ />
257
+ </td>
258
+ {/if}
259
+ {#each columns as col}
260
+ <td class="table-td">
261
+ {render_cell(col, row[col.key], row)}
262
+ </td>
263
+ {/each}
264
+ </tr>
265
+ {/each}
266
+ {/if}
267
+ </tbody>
268
+ </table>
269
+ </div>
270
+ </div>
271
+
272
+ <style>
273
+ .table-wrap {
274
+ width: 100%;
275
+ display: flex;
276
+ flex-direction: column;
277
+ border: var(--elevation-border);
278
+ border-radius: var(--radius-md);
279
+ overflow: hidden;
280
+ }
281
+
282
+ .table-toolbar {
283
+ padding: var(--space-sm) var(--space-md);
284
+ border-bottom: var(--elevation-border);
285
+ background: var(--color-surface);
286
+ }
287
+
288
+ .table-scroll {
289
+ width: 100%;
290
+ overflow: auto;
291
+ /* When a max-height is set on .table-wrap, this enables sticky thead. */
292
+ max-height: inherit;
293
+ }
294
+
295
+ .table {
296
+ width: 100%;
297
+ border-collapse: separate;
298
+ border-spacing: 0;
299
+ }
300
+
301
+ /* ─── Head ───
302
+ Sticky headers require the consumer to set a fixed height on .table-wrap
303
+ or a scroll-owning ancestor. Without a constrained height the header will
304
+ simply sit at the top of the table and not "stick" because this scroll
305
+ container owns the overflow. */
306
+ .table-head {
307
+ position: sticky;
308
+ top: 0;
309
+ z-index: 1;
310
+ }
311
+
312
+ .table-th {
313
+ background: var(--color-surface-tertiary);
314
+ font-family: var(--type-label-font);
315
+ font-size: var(--type-label-size);
316
+ letter-spacing: var(--type-label-tracking);
317
+ color: var(--color-text-secondary);
318
+ font-weight: var(--raw-font-weight-regular, 400);
319
+ text-align: left;
320
+ padding: var(--space-sm) var(--space-md);
321
+ white-space: nowrap;
322
+ user-select: none;
323
+ border-bottom: var(--elevation-border);
324
+ }
325
+
326
+ .table-th-sortable {
327
+ cursor: pointer;
328
+ }
329
+
330
+ .table-th-sortable:hover {
331
+ color: var(--color-text);
332
+ }
333
+
334
+ .table-th-check {
335
+ width: 40px;
336
+ padding-left: var(--space-md);
337
+ padding-right: var(--space-sm);
338
+ }
339
+
340
+ .table-th-inner {
341
+ display: inline-flex;
342
+ align-items: center;
343
+ gap: var(--space-xs);
344
+ }
345
+
346
+ .table-sort-icon {
347
+ display: inline-flex;
348
+ align-items: center;
349
+ color: var(--color-text-muted);
350
+ flex-shrink: 0;
351
+ }
352
+
353
+ .table-th-sortable:hover .table-sort-icon {
354
+ color: var(--color-text-secondary);
355
+ }
356
+
357
+ /* ─── Body ─── */
358
+ .table-body {
359
+ background: var(--color-surface);
360
+ }
361
+
362
+ .table-row {
363
+ transition: background var(--duration-instant) var(--easing-default);
364
+ }
365
+
366
+ .table-row-even {
367
+ background: var(--color-surface-secondary);
368
+ }
369
+
370
+ .table-row:hover {
371
+ background: var(--color-surface-tertiary);
372
+ }
373
+
374
+ .table-row-selected {
375
+ background: var(--color-accent-subtle) !important;
376
+ }
377
+
378
+ .table-row-clickable {
379
+ cursor: pointer;
380
+ }
381
+
382
+ .table-row-clickable:focus-visible {
383
+ outline: var(--focus-ring-width) solid var(--focus-ring-color);
384
+ outline-offset: -2px;
385
+ }
386
+
387
+ .table-td {
388
+ font-family: var(--type-data-font);
389
+ font-size: var(--type-data-size);
390
+ color: var(--color-text);
391
+ padding: var(--space-sm) var(--space-md);
392
+ border-bottom: var(--elevation-border);
393
+ vertical-align: middle;
394
+ }
395
+
396
+ /* Remove border from last row */
397
+ .table-body tr:last-child .table-td {
398
+ border-bottom: none;
399
+ }
400
+
401
+ .table-td-check {
402
+ width: 40px;
403
+ padding-left: var(--space-md);
404
+ padding-right: var(--space-sm);
405
+ }
406
+
407
+ .table-td-empty {
408
+ padding: 0;
409
+ border-bottom: none;
410
+ }
411
+
412
+ /* ─── Checkbox ─── */
413
+ .table-checkbox {
414
+ width: var(--checkbox-size);
415
+ height: var(--checkbox-size);
416
+ accent-color: var(--color-accent);
417
+ cursor: pointer;
418
+ display: block;
419
+ }
420
+ </style>
@@ -0,0 +1,75 @@
1
+ export default DataTable;
2
+ type DataTable = {
3
+ $on?(type: string, callback: (e: any) => void): () => void;
4
+ $set?(props: Partial<$$ComponentProps>): void;
5
+ };
6
+ /**
7
+ * DataTable
8
+ *
9
+ * Data table with sortable columns, row selection, loading and empty states.
10
+ * Composes Skeleton and EmptyState primitives. Consumes --table-* tokens from
11
+ * components.css, falling back to semantic tokens.
12
+ *
13
+ * @example Basic
14
+ * <DataTable
15
+ * columns={[
16
+ * { key: 'name', label: 'NAME' },
17
+ * { key: 'status', label: 'STATUS', width: '120px' },
18
+ * ]}
19
+ * rows={items}
20
+ * on_sort={(key, dir) => sort(key, dir)}
21
+ * on_row_click={(row) => goto(`/items/${row.id}`)}
22
+ * />
23
+ *
24
+ * @example Selectable
25
+ * <DataTable
26
+ * {columns}
27
+ * {rows}
28
+ * selectable
29
+ * bind:selected_rows={selected}
30
+ * on_select={(sel) => console.log(sel)}
31
+ * />
32
+ *
33
+ * @example Loading
34
+ * <DataTable {columns} rows={[]} loading />
35
+ */
36
+ declare const DataTable: import("svelte").Component<
37
+ {
38
+ columns?: any[];
39
+ rows?: any[];
40
+ loading?: boolean;
41
+ sortable?: boolean;
42
+ sort_key?: any;
43
+ sort_direction?: string;
44
+ selectable?: boolean;
45
+ selected_rows?: Set<string>;
46
+ row_key?: string;
47
+ empty_heading?: string;
48
+ empty_body?: string;
49
+ on_sort?: any;
50
+ on_select?: any;
51
+ on_row_click?: any;
52
+ children?: any;
53
+ class?: string;
54
+ } & Record<string, any>,
55
+ {},
56
+ "selected_rows"
57
+ >;
58
+ type $$ComponentProps = {
59
+ columns?: any[];
60
+ rows?: any[];
61
+ loading?: boolean;
62
+ sortable?: boolean;
63
+ sort_key?: any;
64
+ sort_direction?: string;
65
+ selectable?: boolean;
66
+ selected_rows?: Set<string>;
67
+ row_key?: string;
68
+ empty_heading?: string;
69
+ empty_body?: string;
70
+ on_sort?: any;
71
+ on_select?: any;
72
+ on_row_click?: any;
73
+ children?: any;
74
+ class?: string;
75
+ } & Record<string, any>;
@@ -0,0 +1,299 @@
1
+ <!--
2
+ @component Pagination
3
+
4
+ Cursor-based and offset-based pagination with page size selector.
5
+ Consumes --pagination-* tokens (falls back to semantic tokens).
6
+
7
+ @example Cursor mode (default)
8
+ <Pagination
9
+ has_next={page.has_next}
10
+ has_prev={page.has_prev}
11
+ on_next={() => loadNext()}
12
+ on_prev={() => loadPrev()}
13
+ total_items={page.count}
14
+ page_size={25}
15
+ />
16
+
17
+ @example Offset mode
18
+ <Pagination
19
+ mode="offset"
20
+ current_page={page}
21
+ total_pages={totalPages}
22
+ total_items={totalItems}
23
+ on_page_change={(p) => setPage(p)}
24
+ page_sizes={[10, 25, 50, 100]}
25
+ on_page_size_change={(s) => setSize(s)}
26
+ />
27
+ -->
28
+ <script>
29
+ import Select from './Select.svelte';
30
+ import Button from './Button.svelte';
31
+
32
+ let {
33
+ /** @type {'cursor' | 'offset'} */
34
+ mode = 'cursor',
35
+
36
+ /* Cursor mode */
37
+ /** @type {boolean} */
38
+ has_next = false,
39
+ /** @type {boolean} */
40
+ has_prev = false,
41
+ /** @type {(() => void) | undefined} */
42
+ on_next = undefined,
43
+ /** @type {(() => void) | undefined} */
44
+ on_prev = undefined,
45
+
46
+ /* Offset mode */
47
+ /** @type {number} */
48
+ current_page = 1,
49
+ /** @type {number | undefined} */
50
+ total_pages = undefined,
51
+ /** @type {number | undefined} */
52
+ total_items = undefined,
53
+ /** @type {((page: number) => void) | undefined} */
54
+ on_page_change = undefined,
55
+
56
+ /* Shared */
57
+ /** @type {number} */
58
+ page_size = 25,
59
+ /** @type {number[] | undefined} */
60
+ page_sizes = undefined,
61
+ /** @type {((size: number) => void) | undefined} */
62
+ on_page_size_change = undefined,
63
+
64
+ /** @type {string} */
65
+ class: className = '',
66
+ ...rest
67
+ } = $props();
68
+
69
+ const page_size_options = $derived(
70
+ (page_sizes ?? []).map((s) => ({ value: String(s), label: String(s) }))
71
+ );
72
+
73
+ const page_size_value = $derived(String(page_size));
74
+
75
+ /** Compute visible page numbers with ellipsis for offset mode */
76
+ const page_numbers = $derived(
77
+ mode === 'offset' && total_pages
78
+ ? build_pages(current_page, total_pages)
79
+ : /** @type {(number | null)[]} */ ([])
80
+ );
81
+
82
+ const item_range_text = $derived(
83
+ total_items === undefined
84
+ ? ''
85
+ : mode === 'cursor'
86
+ ? `${total_items.toLocaleString()} total`
87
+ : (() => {
88
+ const start = (current_page - 1) * page_size + 1;
89
+ const end = Math.min(current_page * page_size, total_items);
90
+ return start > total_items
91
+ ? `${total_items.toLocaleString()} total`
92
+ : `${start}–${end} of ${total_items.toLocaleString()}`;
93
+ })()
94
+ );
95
+
96
+ /**
97
+ * @param {number} current
98
+ * @param {number} total
99
+ * @returns {(number | null)[]}
100
+ */
101
+ function build_pages(current, total) {
102
+ if (total <= 7) {
103
+ return Array.from({ length: total }, (_, i) => i + 1);
104
+ }
105
+ const pages = /** @type {(number | null)[]} */ ([]);
106
+ // Always show first
107
+ pages.push(1);
108
+ if (current > 3) pages.push(null); // left ellipsis
109
+ // Window around current
110
+ const win_start = Math.max(2, current - 1);
111
+ const win_end = Math.min(total - 1, current + 1);
112
+ for (let p = win_start; p <= win_end; p++) pages.push(p);
113
+ if (current < total - 2) pages.push(null); // right ellipsis
114
+ // Always show last
115
+ pages.push(total);
116
+ return pages;
117
+ }
118
+ </script>
119
+
120
+ <div class="pagination {className}" {...rest}>
121
+ {#if page_sizes && page_sizes.length > 0}
122
+ <div class="pagination-size">
123
+ <Select
124
+ size="sm"
125
+ value={page_size_value}
126
+ options={page_size_options}
127
+ onchange={(val) => on_page_size_change?.(Number(val))}
128
+ aria-label="Rows per page"
129
+ />
130
+ </div>
131
+ {/if}
132
+
133
+ {#if total_items !== undefined}
134
+ <span class="pagination-info">{item_range_text}</span>
135
+ {/if}
136
+
137
+ <span class="pagination-spacer" aria-hidden="true"></span>
138
+
139
+ {#if mode === 'cursor'}
140
+ <Button
141
+ variant="ghost"
142
+ size="sm"
143
+ disabled={!has_prev}
144
+ onclick={on_prev}
145
+ aria-label="Previous page"
146
+ >
147
+ {#snippet icon()}
148
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
149
+ <path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
150
+ </svg>
151
+ {/snippet}
152
+ PREV
153
+ </Button>
154
+
155
+ <Button
156
+ variant="ghost"
157
+ size="sm"
158
+ disabled={!has_next}
159
+ onclick={on_next}
160
+ aria-label="Next page"
161
+ >
162
+ NEXT
163
+ {#snippet icon()}
164
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
165
+ <path d="M6 12L10 8L6 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
166
+ </svg>
167
+ {/snippet}
168
+ </Button>
169
+
170
+ {:else}
171
+ <Button
172
+ variant="ghost"
173
+ size="sm"
174
+ disabled={current_page <= 1}
175
+ onclick={() => on_page_change?.(current_page - 1)}
176
+ aria-label="Previous page"
177
+ >
178
+ {#snippet icon()}
179
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
180
+ <path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
181
+ </svg>
182
+ {/snippet}
183
+ PREV
184
+ </Button>
185
+
186
+ <div class="pagination-pages">
187
+ {#each page_numbers as p}
188
+ {#if p === null}
189
+ <span class="pagination-ellipsis" aria-hidden="true">…</span>
190
+ {:else}
191
+ <button
192
+ type="button"
193
+ class="pagination-page"
194
+ class:pagination-page-active={p === current_page}
195
+ onclick={() => on_page_change?.(/** @type {number} */ (p))}
196
+ aria-label={`Page ${p}`}
197
+ aria-current={p === current_page ? 'page' : undefined}
198
+ >
199
+ {p}
200
+ </button>
201
+ {/if}
202
+ {/each}
203
+ </div>
204
+
205
+ <Button
206
+ variant="ghost"
207
+ size="sm"
208
+ disabled={current_page >= (total_pages ?? current_page)}
209
+ onclick={() => on_page_change?.(current_page + 1)}
210
+ aria-label="Next page"
211
+ >
212
+ NEXT
213
+ {#snippet icon()}
214
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
215
+ <path d="M6 12L10 8L6 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
216
+ </svg>
217
+ {/snippet}
218
+ </Button>
219
+ {/if}
220
+ </div>
221
+
222
+ <style>
223
+ .pagination {
224
+ display: flex;
225
+ align-items: center;
226
+ gap: var(--space-sm);
227
+ flex-wrap: wrap;
228
+ }
229
+
230
+ .pagination-size {
231
+ /* Constrain size selector width */
232
+ width: 80px;
233
+ }
234
+
235
+ .pagination-info {
236
+ font-family: var(--type-body-sm-font);
237
+ font-size: var(--type-body-sm-size);
238
+ color: var(--color-text-muted);
239
+ white-space: nowrap;
240
+ }
241
+
242
+ .pagination-spacer {
243
+ flex: 1;
244
+ min-width: var(--space-sm);
245
+ }
246
+
247
+ /* ─── Offset page numbers ─── */
248
+ .pagination-pages {
249
+ display: flex;
250
+ align-items: center;
251
+ gap: var(--space-2xs);
252
+ }
253
+
254
+ .pagination-page {
255
+ display: inline-flex;
256
+ align-items: center;
257
+ justify-content: center;
258
+ min-width: 28px;
259
+ height: 28px;
260
+ padding: 0 var(--space-xs);
261
+ font-family: var(--type-label-font);
262
+ font-size: var(--type-label-size);
263
+ letter-spacing: var(--type-label-tracking);
264
+ color: var(--color-text-secondary);
265
+ background: transparent;
266
+ border: none;
267
+ border-radius: var(--radius-sm);
268
+ cursor: pointer;
269
+ transition: background var(--duration-instant) var(--easing-default);
270
+ }
271
+
272
+ .pagination-page:hover:not(.pagination-page-active) {
273
+ background: var(--color-surface-secondary);
274
+ color: var(--color-text);
275
+ }
276
+
277
+ .pagination-page:focus-visible {
278
+ outline: var(--focus-ring-width) solid var(--focus-ring-color);
279
+ outline-offset: var(--focus-ring-offset);
280
+ }
281
+
282
+ .pagination-page-active {
283
+ background: var(--color-accent);
284
+ color: var(--color-text-on-accent);
285
+ cursor: default;
286
+ }
287
+
288
+ .pagination-ellipsis {
289
+ display: inline-flex;
290
+ align-items: center;
291
+ justify-content: center;
292
+ min-width: 28px;
293
+ height: 28px;
294
+ font-family: var(--type-label-font);
295
+ font-size: var(--type-label-size);
296
+ color: var(--color-text-muted);
297
+ user-select: none;
298
+ }
299
+ </style>
@@ -0,0 +1,66 @@
1
+ export default Pagination;
2
+ type Pagination = {
3
+ $on?(type: string, callback: (e: any) => void): () => void;
4
+ $set?(props: Partial<$$ComponentProps>): void;
5
+ };
6
+ /**
7
+ * Pagination
8
+ *
9
+ * Cursor-based and offset-based pagination with page size selector.
10
+ * Consumes --pagination-* tokens (falls back to semantic tokens).
11
+ *
12
+ * @example Cursor mode (default)
13
+ * <Pagination
14
+ * has_next={page.has_next}
15
+ * has_prev={page.has_prev}
16
+ * on_next={() => loadNext()}
17
+ * on_prev={() => loadPrev()}
18
+ * total_items={page.count}
19
+ * page_size={25}
20
+ * />
21
+ *
22
+ * @example Offset mode
23
+ * <Pagination
24
+ * mode="offset"
25
+ * current_page={page}
26
+ * total_pages={totalPages}
27
+ * total_items={totalItems}
28
+ * on_page_change={(p) => setPage(p)}
29
+ * page_sizes={[10, 25, 50, 100]}
30
+ * on_page_size_change={(s) => setSize(s)}
31
+ * />
32
+ */
33
+ declare const Pagination: import("svelte").Component<
34
+ {
35
+ mode?: string;
36
+ has_next?: boolean;
37
+ has_prev?: boolean;
38
+ on_next?: any;
39
+ on_prev?: any;
40
+ current_page?: number;
41
+ total_pages?: any;
42
+ total_items?: any;
43
+ on_page_change?: any;
44
+ page_size?: number;
45
+ page_sizes?: number[];
46
+ on_page_size_change?: any;
47
+ class?: string;
48
+ } & Record<string, any>,
49
+ {},
50
+ ""
51
+ >;
52
+ type $$ComponentProps = {
53
+ mode?: string;
54
+ has_next?: boolean;
55
+ has_prev?: boolean;
56
+ on_next?: any;
57
+ on_prev?: any;
58
+ current_page?: number;
59
+ total_pages?: any;
60
+ total_items?: any;
61
+ on_page_change?: any;
62
+ page_size?: number;
63
+ page_sizes?: number[];
64
+ on_page_size_change?: any;
65
+ class?: string;
66
+ } & Record<string, any>;
@@ -47,3 +47,6 @@ export { default as CollapsibleSection } from "./CollapsibleSection.svelte";
47
47
  export { default as OptionGrid } from "./OptionGrid.svelte";
48
48
  export { default as ConditionTable } from "./ConditionTable.svelte";
49
49
  export { default as LogViewer } from "./LogViewer.svelte";
50
+ export { default as DataTable } from "./DataTable.svelte";
51
+ export { default as Pagination } from "./Pagination.svelte";
52
+ export { default as Breadcrumb } from "./Breadcrumb.svelte";
@@ -76,3 +76,8 @@ export { default as CollapsibleSection } from "./CollapsibleSection.svelte";
76
76
  export { default as OptionGrid } from "./OptionGrid.svelte";
77
77
  export { default as ConditionTable } from "./ConditionTable.svelte";
78
78
  export { default as LogViewer } from "./LogViewer.svelte";
79
+
80
+ // Data & navigation
81
+ export { default as DataTable } from "./DataTable.svelte";
82
+ export { default as Pagination } from "./Pagination.svelte";
83
+ export { default as Breadcrumb } from "./Breadcrumb.svelte";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiaiai-pt/design-system",
3
- "version": "0.3.7",
3
+ "version": "0.4.0",
4
4
  "description": "Design system tokens and Svelte components for aiaiai products",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,6 +27,11 @@
27
27
  "svelte": "./components/index.js",
28
28
  "default": "./components/index.js"
29
29
  },
30
+ "./components/*.svelte": {
31
+ "types": "./components/*.svelte.d.ts",
32
+ "svelte": "./components/*.svelte",
33
+ "default": "./components/*.svelte"
34
+ },
30
35
  "./components/*": {
31
36
  "svelte": "./components/*",
32
37
  "default": "./components/*"