@realmorg/ui 0.0.0-stage → 2.0.0-alpha.2

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 (66) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +44 -2
  3. package/components/accordion.html +121 -0
  4. package/components/alert-dialog.html +196 -0
  5. package/components/alert.html +69 -0
  6. package/components/aspect-ratio.html +23 -0
  7. package/components/attachment.html +84 -0
  8. package/components/avatar.html +127 -0
  9. package/components/badge.html +56 -0
  10. package/components/breadcrumb.html +129 -0
  11. package/components/bubble.html +67 -0
  12. package/components/button-group.html +84 -0
  13. package/components/button.html +188 -0
  14. package/components/calendar.html +170 -0
  15. package/components/card.html +113 -0
  16. package/components/carousel.html +186 -0
  17. package/components/chart.html +162 -0
  18. package/components/checkbox.html +93 -0
  19. package/components/code-block.html +58 -0
  20. package/components/collapsible.html +76 -0
  21. package/components/combobox.html +224 -0
  22. package/components/command.html +189 -0
  23. package/components/context-menu.html +67 -0
  24. package/components/dialog.html +281 -0
  25. package/components/direction.html +17 -0
  26. package/components/drawer.html +224 -0
  27. package/components/dropdown-menu.html +372 -0
  28. package/components/empty.html +106 -0
  29. package/components/field.html +117 -0
  30. package/components/hover-card.html +94 -0
  31. package/components/input-group.html +75 -0
  32. package/components/input-otp.html +139 -0
  33. package/components/input.html +82 -0
  34. package/components/item.html +170 -0
  35. package/components/kbd.html +35 -0
  36. package/components/label.html +40 -0
  37. package/components/marker.html +35 -0
  38. package/components/menubar.html +116 -0
  39. package/components/message-scroller.html +62 -0
  40. package/components/message.html +77 -0
  41. package/components/navigation-menu.html +204 -0
  42. package/components/pagination.html +154 -0
  43. package/components/popover.html +133 -0
  44. package/components/progress.html +39 -0
  45. package/components/radio-group.html +114 -0
  46. package/components/resizable.html +156 -0
  47. package/components/scroll-area.html +50 -0
  48. package/components/select.html +91 -0
  49. package/components/separator.html +25 -0
  50. package/components/sheet.html +143 -0
  51. package/components/sidebar.html +343 -0
  52. package/components/skeleton.html +30 -0
  53. package/components/slider.html +81 -0
  54. package/components/spinner.html +47 -0
  55. package/components/switch.html +89 -0
  56. package/components/table.html +75 -0
  57. package/components/tabs.html +164 -0
  58. package/components/textarea.html +68 -0
  59. package/components/toaster.html +128 -0
  60. package/components/toggle-group.html +65 -0
  61. package/components/toggle.html +81 -0
  62. package/components/tooltip.html +97 -0
  63. package/dist/theme.css +145 -0
  64. package/dist/ui.html +6944 -0
  65. package/package.json +40 -3
  66. package/theme.css +145 -0
@@ -0,0 +1,343 @@
1
+ <import-element from="./button.html"></import-element>
2
+
3
+ <custom-element name="ui-sidebar-layout">
4
+ <script>
5
+ const props = defineProps({
6
+ collapsed: prop.boolean().reflect(),
7
+ persist: prop.string(),
8
+ });
9
+
10
+ const media = matchMedia("(max-width: 767px)");
11
+ const layout = useState({ mobile: media.matches, open: false });
12
+
13
+ function toggle() {
14
+ if (layout.mobile) layout.open = !layout.open;
15
+ else props.collapsed = !props.collapsed;
16
+ }
17
+
18
+ function close() {
19
+ layout.open = false;
20
+ }
21
+
22
+ function shortcut(event) {
23
+ if (event.key.toLowerCase() !== "b" || !(event.metaKey || event.ctrlKey)) return;
24
+ event.preventDefault();
25
+ toggle();
26
+ }
27
+
28
+ if (props.persist) props.collapsed = localStorage.getItem(props.persist) === "true";
29
+
30
+ defineMethods({ toggle });
31
+
32
+ listen("sidebartoggle", toggle);
33
+ listen("keydown", shortcut, document);
34
+ listen("change", () => (layout.mobile = media.matches), media);
35
+
36
+ observe(() => {
37
+ if (props.persist) localStorage.setItem(props.persist, String(props.collapsed));
38
+ });
39
+ </script>
40
+
41
+ <style>
42
+ :host {
43
+ --ui-sidebar-width: calc(var(--spacing) * 64);
44
+ --ui-sidebar-label: block;
45
+ display: flex;
46
+ min-height: 100dvh;
47
+ background: var(--sidebar);
48
+ }
49
+ :host(:state(attr-collapsed)) {
50
+ --ui-sidebar-width: calc(var(--spacing) * 12);
51
+ --ui-sidebar-label: none;
52
+ }
53
+ .sidebar {
54
+ position: sticky;
55
+ top: 0;
56
+ width: var(--ui-sidebar-width);
57
+ height: 100dvh;
58
+ flex-shrink: 0;
59
+ transition: width 0.2s ease;
60
+ }
61
+ .backdrop {
62
+ display: none;
63
+ }
64
+ :host(:state(state-layout-mobile)) {
65
+ --ui-sidebar-width: calc(var(--spacing) * 72);
66
+ --ui-sidebar-label: block;
67
+ --ui-sidebar-inset-margin: 0;
68
+ --ui-sidebar-inset-radius: 0;
69
+ }
70
+ :host(:state(state-layout-mobile)) .sidebar {
71
+ position: fixed;
72
+ inset: 0 auto 0 0;
73
+ z-index: 50;
74
+ translate: -100% 0;
75
+ transition: translate 0.2s ease;
76
+ }
77
+ :host(:state(state-layout-open)) .sidebar {
78
+ translate: 0 0;
79
+ }
80
+ :host(:state(state-layout-open)) .backdrop {
81
+ position: fixed;
82
+ inset: 0;
83
+ z-index: 40;
84
+ display: block;
85
+ background: rgb(0 0 0 / 0.5);
86
+ }
87
+ </style>
88
+
89
+ <aside class="sidebar" part="sidebar"><slot name="sidebar"></slot></aside>
90
+ <div class="backdrop" part="backdrop" onclick="close()"></div>
91
+ <slot></slot>
92
+ </custom-element>
93
+
94
+ <custom-element name="ui-sidebar" slot="sidebar">
95
+ <style>
96
+ :host {
97
+ display: flex;
98
+ flex-direction: column;
99
+ height: 100%;
100
+ box-sizing: border-box;
101
+ background: var(--sidebar);
102
+ color: var(--sidebar-foreground);
103
+ overflow: hidden;
104
+ }
105
+ </style>
106
+
107
+ <slot name="header"></slot>
108
+ <slot></slot>
109
+ <slot name="footer"></slot>
110
+ </custom-element>
111
+
112
+ <custom-element name="ui-sidebar-header" slot="header">
113
+ <style>
114
+ :host {
115
+ display: flex;
116
+ flex-direction: column;
117
+ gap: calc(var(--spacing) * 2);
118
+ padding: calc(var(--spacing) * 2);
119
+ }
120
+ </style>
121
+
122
+ <slot></slot>
123
+ </custom-element>
124
+
125
+ <custom-element name="ui-sidebar-footer" slot="footer">
126
+ <style>
127
+ :host {
128
+ display: flex;
129
+ flex-direction: column;
130
+ gap: calc(var(--spacing) * 2);
131
+ padding: calc(var(--spacing) * 2);
132
+ }
133
+ </style>
134
+
135
+ <slot></slot>
136
+ </custom-element>
137
+
138
+ <custom-element name="ui-sidebar-content">
139
+ <style>
140
+ :host {
141
+ display: block;
142
+ flex: 1;
143
+ min-height: 0;
144
+ overflow: hidden auto;
145
+ }
146
+ nav {
147
+ display: flex;
148
+ flex-direction: column;
149
+ gap: calc(var(--spacing) * 2);
150
+ }
151
+ </style>
152
+
153
+ <nav part="content"><slot></slot></nav>
154
+ </custom-element>
155
+
156
+ <custom-element name="ui-sidebar-group" role="group">
157
+ <script>
158
+ const labels = useChildren("ui-sidebar-group-label");
159
+
160
+ observe(() => {
161
+ internals.ariaLabelledByElements = [...labels];
162
+ });
163
+ </script>
164
+
165
+ <style>
166
+ :host {
167
+ display: flex;
168
+ flex-direction: column;
169
+ padding: calc(var(--spacing) * 2);
170
+ }
171
+ </style>
172
+
173
+ <slot></slot>
174
+ </custom-element>
175
+
176
+ <custom-element name="ui-sidebar-group-label">
177
+ <style>
178
+ :host {
179
+ display: var(--ui-sidebar-label, block);
180
+ height: calc(var(--spacing) * 8);
181
+ padding: 0 calc(var(--spacing) * 2);
182
+ font-size: var(--text-xs);
183
+ font-weight: var(--font-weight-medium);
184
+ line-height: calc(var(--spacing) * 8);
185
+ white-space: nowrap;
186
+ color: color-mix(in oklab, var(--sidebar-foreground) 70%, transparent);
187
+ }
188
+ </style>
189
+
190
+ <slot></slot>
191
+ </custom-element>
192
+
193
+ <custom-element name="ui-sidebar-menu">
194
+ <style>
195
+ :host {
196
+ display: block;
197
+ }
198
+ ul {
199
+ display: flex;
200
+ flex-direction: column;
201
+ gap: calc(var(--spacing) * 1);
202
+ margin: 0;
203
+ padding: 0;
204
+ list-style: none;
205
+ }
206
+ </style>
207
+
208
+ <ul part="menu" focusgroup="menu block"><slot></slot></ul>
209
+ </custom-element>
210
+
211
+ <custom-element name="ui-sidebar-menu-item" role="listitem">
212
+ <style>
213
+ :host {
214
+ position: relative;
215
+ display: block;
216
+ }
217
+ </style>
218
+
219
+ <slot></slot>
220
+ </custom-element>
221
+
222
+ <custom-element name="ui-sidebar-menu-button">
223
+ <script>
224
+ const props = defineProps({
225
+ href: prop.string(),
226
+ current: prop.boolean(),
227
+ label: prop.string(),
228
+ });
229
+
230
+ const item = useState({
231
+ get current() {
232
+ return props.current ? "page" : null;
233
+ },
234
+ get role() {
235
+ return props.href ? null : "button";
236
+ },
237
+ });
238
+
239
+ const ACTIVATE = { Enter: true, " ": true };
240
+
241
+ function pressed(event) {
242
+ if (!dispatch("select")) event.preventDefault();
243
+ }
244
+
245
+ function key(event) {
246
+ if (props.href || !ACTIVATE[event.key]) return;
247
+ event.preventDefault();
248
+ event.currentTarget.click();
249
+ }
250
+ </script>
251
+
252
+ <style>
253
+ :host {
254
+ display: block;
255
+ }
256
+ a {
257
+ display: flex;
258
+ align-items: center;
259
+ gap: calc(var(--spacing) * 2);
260
+ height: calc(var(--spacing) * 8);
261
+ padding: 0 calc(var(--spacing) * 2);
262
+ border-radius: var(--radius-md);
263
+ overflow: hidden;
264
+ color: inherit;
265
+ font-size: var(--text-sm);
266
+ text-decoration: none;
267
+ white-space: nowrap;
268
+ outline: none;
269
+ cursor: pointer;
270
+ }
271
+ a:hover,
272
+ a:focus-visible,
273
+ :host(:state(attr-current)) a {
274
+ background: var(--sidebar-accent);
275
+ }
276
+ :host(:state(attr-current)) a {
277
+ font-weight: var(--font-weight-medium);
278
+ }
279
+ a:focus-visible {
280
+ box-shadow: 0 0 0 2px var(--ring);
281
+ }
282
+ ::slotted(svg) {
283
+ width: calc(var(--spacing) * 4);
284
+ height: calc(var(--spacing) * 4);
285
+ flex-shrink: 0;
286
+ }
287
+ </style>
288
+
289
+ <a part="button" _href="@href" _title="@label" _aria-current="#item.current" _role="#item.role" tabindex="0" onclick="pressed(event)" onkeydown="key(event)"><slot></slot></a>
290
+ </custom-element>
291
+
292
+ <custom-element name="ui-sidebar-menu-badge">
293
+ <style>
294
+ :host {
295
+ display: var(--ui-sidebar-label, block);
296
+ position: absolute;
297
+ top: 0;
298
+ right: calc(var(--spacing) * 1);
299
+ padding: 0 calc(var(--spacing) * 1);
300
+ line-height: calc(var(--spacing) * 8);
301
+ font-size: var(--text-xs);
302
+ font-weight: var(--font-weight-medium);
303
+ color: color-mix(in oklab, var(--sidebar-foreground) 70%, transparent);
304
+ pointer-events: none;
305
+ }
306
+ </style>
307
+
308
+ <slot></slot>
309
+ </custom-element>
310
+
311
+ <custom-element name="ui-sidebar-trigger">
312
+ <script>
313
+ function toggle() {
314
+ dispatch("sidebartoggle");
315
+ }
316
+ </script>
317
+
318
+ <ui-button variant="ghost" size="icon-sm" aria-label="Toggle sidebar" onclick="toggle()">
319
+ <svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M9 3v18" /></svg>
320
+ </ui-button>
321
+ </custom-element>
322
+
323
+ <custom-element name="ui-sidebar-inset">
324
+ <style>
325
+ :host {
326
+ display: flex;
327
+ flex: 1;
328
+ min-width: 0;
329
+ }
330
+ main {
331
+ display: flex;
332
+ flex: 1;
333
+ flex-direction: column;
334
+ min-width: 0;
335
+ margin: var(--ui-sidebar-inset-margin, calc(var(--spacing) * 2) calc(var(--spacing) * 2) calc(var(--spacing) * 2) 0);
336
+ border-radius: var(--ui-sidebar-inset-radius, var(--radius-xl));
337
+ background: var(--background);
338
+ box-shadow: var(--shadow-xs);
339
+ }
340
+ </style>
341
+
342
+ <main part="main"><slot></slot></main>
343
+ </custom-element>
@@ -0,0 +1,30 @@
1
+ <custom-element name="ui-skeleton">
2
+ <style>
3
+ :host {
4
+ display: block;
5
+ border-radius: var(--radius-md);
6
+ background: var(--accent);
7
+ animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
8
+ }
9
+ :host(:state(attr-width)) {
10
+ width: calc(var(--spacing) * var(--attr-width));
11
+ }
12
+ :host(:state(attr-height)) {
13
+ height: calc(var(--spacing) * var(--attr-height));
14
+ }
15
+ :host(:state(attr-shape-is-circle)) {
16
+ flex-shrink: 0;
17
+ border-radius: var(--radius-full);
18
+ }
19
+ @keyframes pulse {
20
+ 50% {
21
+ opacity: 0.5;
22
+ }
23
+ }
24
+ @media (prefers-reduced-motion: reduce) {
25
+ :host {
26
+ animation: none;
27
+ }
28
+ }
29
+ </style>
30
+ </custom-element>
@@ -0,0 +1,81 @@
1
+ <custom-element name="ui-slider" form-associated delegates-focus>
2
+ <script>
3
+ const props = defineProps({
4
+ value: prop.number().default(0),
5
+ min: prop.number().default(0),
6
+ max: prop.number().default(100),
7
+ step: prop.number().default(1),
8
+ disabled: prop.boolean(),
9
+ label: prop.string().attribute("aria-label"),
10
+ });
11
+
12
+ const range = useState({
13
+ get percent() {
14
+ return `${((props.value - props.min) / (props.max - props.min || 1)) * 100}%`;
15
+ },
16
+ });
17
+ </script>
18
+
19
+ <style>
20
+ :host {
21
+ display: block;
22
+ min-width: calc(var(--spacing) * 32);
23
+ }
24
+ input {
25
+ --fill: var(--primary);
26
+ appearance: none;
27
+ width: 100%;
28
+ height: calc(var(--spacing) * 4);
29
+ margin: 0;
30
+ background: transparent;
31
+ cursor: pointer;
32
+ outline: none;
33
+ }
34
+ input::-webkit-slider-runnable-track {
35
+ height: calc(var(--spacing) * 1.5);
36
+ border-radius: var(--radius-full);
37
+ background: linear-gradient(to right, var(--fill) var(--state-range-percent), var(--muted) var(--state-range-percent));
38
+ }
39
+ input::-moz-range-track {
40
+ height: calc(var(--spacing) * 1.5);
41
+ border-radius: var(--radius-full);
42
+ background: linear-gradient(to right, var(--fill) var(--state-range-percent), var(--muted) var(--state-range-percent));
43
+ }
44
+ input::-webkit-slider-thumb {
45
+ appearance: none;
46
+ width: calc(var(--spacing) * 4);
47
+ height: calc(var(--spacing) * 4);
48
+ margin-top: calc(var(--spacing) * -1.25);
49
+ border: 1px solid var(--primary);
50
+ border-radius: var(--radius-full);
51
+ background: var(--background);
52
+ box-shadow: var(--shadow-sm);
53
+ }
54
+ input::-moz-range-thumb {
55
+ width: calc(var(--spacing) * 4);
56
+ height: calc(var(--spacing) * 4);
57
+ border: 1px solid var(--primary);
58
+ border-radius: var(--radius-full);
59
+ background: var(--background);
60
+ }
61
+ input:focus-visible::-webkit-slider-thumb {
62
+ box-shadow: 0 0 0 4px color-mix(in oklab, var(--ring) 50%, transparent);
63
+ }
64
+ input:disabled {
65
+ cursor: not-allowed;
66
+ opacity: 0.5;
67
+ }
68
+ </style>
69
+
70
+ <input
71
+ ref="control"
72
+ type="range"
73
+ part="control"
74
+ _min="@min"
75
+ _max="@max"
76
+ _step="@step"
77
+ _value="@value"
78
+ _disabled="@disabled"
79
+ _aria-label="@label"
80
+ >
81
+ </custom-element>
@@ -0,0 +1,47 @@
1
+ <custom-element name="ui-spinner" role="status">
2
+ <script>
3
+ const props = defineProps({
4
+ size: prop.number().min(8).default(16),
5
+ label: prop.string().default("Loading"),
6
+ variant: prop.enum(["default", "primary", "muted", "destructive"]).default("default"),
7
+ });
8
+
9
+ observe(() => {
10
+ internals.ariaLabel = props.label;
11
+ });
12
+ </script>
13
+
14
+ <style>
15
+ :host {
16
+ display: inline-flex;
17
+ width: calc(var(--attr-size) * 1px);
18
+ height: calc(var(--attr-size) * 1px);
19
+ }
20
+ :host(:state(attr-variant-is-primary)) {
21
+ color: var(--primary);
22
+ }
23
+ :host(:state(attr-variant-is-muted)) {
24
+ color: var(--muted-foreground);
25
+ }
26
+ :host(:state(attr-variant-is-destructive)) {
27
+ color: var(--destructive);
28
+ }
29
+ svg {
30
+ width: 100%;
31
+ height: 100%;
32
+ animation: spin 0.8s linear infinite;
33
+ }
34
+ @keyframes spin {
35
+ to {
36
+ rotate: 360deg;
37
+ }
38
+ }
39
+ @media (prefers-reduced-motion: reduce) {
40
+ svg {
41
+ animation-duration: 2.4s;
42
+ }
43
+ }
44
+ </style>
45
+
46
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
47
+ </custom-element>
@@ -0,0 +1,89 @@
1
+ <custom-element name="ui-switch" form-associated delegates-focus>
2
+ <script>
3
+ defineProps({
4
+ checked: prop.boolean(),
5
+ value: prop.string(),
6
+ size: prop.enum(["default", "sm"]).default("default"),
7
+ disabled: prop.boolean(),
8
+ label: prop.string().attribute("aria-label"),
9
+ });
10
+ </script>
11
+
12
+ <style>
13
+ :host(:state(attr-checked)) label {
14
+ color: var(--foreground);
15
+ }
16
+ :host {
17
+ display: inline-flex;
18
+ align-items: center;
19
+ gap: calc(var(--spacing) * 2);
20
+ vertical-align: middle;
21
+ }
22
+ input {
23
+ appearance: none;
24
+ box-sizing: border-box;
25
+ position: relative;
26
+ width: calc(var(--spacing) * 8);
27
+ height: calc(var(--spacing) * 4.6);
28
+ margin: 0;
29
+ border: 1px solid transparent;
30
+ border-radius: var(--radius-full);
31
+ background: var(--input);
32
+ box-shadow: var(--shadow-xs);
33
+ cursor: pointer;
34
+ outline: none;
35
+ transition: background-color var(--default-transition-duration), box-shadow var(--default-transition-duration);
36
+ }
37
+ input::before {
38
+ content: "";
39
+ position: absolute;
40
+ top: 50%;
41
+ left: 0;
42
+ width: calc(var(--spacing) * 4);
43
+ height: calc(var(--spacing) * 4);
44
+ border-radius: var(--radius-full);
45
+ background: var(--background);
46
+ translate: 0 -50%;
47
+ transition: translate var(--default-transition-duration);
48
+ }
49
+ input:checked {
50
+ background: var(--primary);
51
+ }
52
+ input:checked::before {
53
+ translate: calc(100% - 2px) -50%;
54
+ }
55
+ :host(:state(attr-size-is-sm)) input {
56
+ width: calc(var(--spacing) * 6);
57
+ height: calc(var(--spacing) * 3.5);
58
+ }
59
+ :host(:state(attr-size-is-sm)) input::before {
60
+ width: calc(var(--spacing) * 3);
61
+ height: calc(var(--spacing) * 3);
62
+ }
63
+ input:focus-visible {
64
+ border-color: var(--ring);
65
+ box-shadow: var(--focus-ring);
66
+ }
67
+ input:disabled {
68
+ cursor: not-allowed;
69
+ opacity: 0.5;
70
+ }
71
+ label {
72
+ font-size: var(--text-sm);
73
+ font-weight: var(--font-weight-medium);
74
+ }
75
+ </style>
76
+
77
+ <input
78
+ id="control"
79
+ ref="control"
80
+ part="control"
81
+ type="checkbox"
82
+ _aria-label="@label"
83
+ role="switch"
84
+ _checked="@checked"
85
+ _aria-checked="@checked"
86
+ _disabled="@disabled"
87
+ >
88
+ <label for="control" part="label"><slot></slot></label>
89
+ </custom-element>
@@ -0,0 +1,75 @@
1
+ <custom-element name="ui-table">
2
+ <script>
3
+ const props = defineProps({
4
+ columns: prop.array(prop.object({ key: prop.string().required(), label: prop.string() })).default([]),
5
+ rows: prop.array(prop.object()).default([]),
6
+ caption: prop.string(),
7
+ });
8
+
9
+ const table = useState({
10
+ get headers() {
11
+ return props.columns.map((column) => ({ key: column.key, label: column.label ?? column.key }));
12
+ },
13
+ get grid() {
14
+ return props.rows.map((row, index) => ({
15
+ id: row.id ?? index,
16
+ cells: props.columns.map((column) => ({ key: column.key, value: row[column.key] ?? "" })),
17
+ }));
18
+ },
19
+ });
20
+ </script>
21
+
22
+ <style>
23
+ :host {
24
+ display: block;
25
+ width: 100%;
26
+ box-sizing: border-box;
27
+ overflow-x: auto;
28
+ }
29
+ table {
30
+ width: 100%;
31
+ border-collapse: collapse;
32
+ font-size: var(--text-sm);
33
+ caption-side: bottom;
34
+ }
35
+ caption {
36
+ margin-top: calc(var(--spacing) * 4);
37
+ color: var(--muted-foreground);
38
+ }
39
+ th {
40
+ height: calc(var(--spacing) * 10);
41
+ padding: 0 calc(var(--spacing) * 2);
42
+ text-align: left;
43
+ font-weight: var(--font-weight-medium);
44
+ white-space: nowrap;
45
+ color: var(--foreground);
46
+ }
47
+ td {
48
+ padding: calc(var(--spacing) * 2);
49
+ white-space: nowrap;
50
+ }
51
+ tr {
52
+ border-bottom: 1px solid var(--border);
53
+ }
54
+ tbody tr:last-child {
55
+ border-bottom: 0;
56
+ }
57
+ tbody tr:hover {
58
+ background: color-mix(in oklab, var(--muted) 50%, transparent);
59
+ }
60
+ </style>
61
+
62
+ <table part="table">
63
+ <caption _hidden="!@caption"><slot name="@caption"></slot></caption>
64
+ <thead>
65
+ <tr>
66
+ <th repeat="#table.headers" as="column" key="key" part="head"><slot name="#column.label"></slot></th>
67
+ </tr>
68
+ </thead>
69
+ <tbody>
70
+ <tr repeat="#table.grid" as="row" key="id" part="row">
71
+ <td repeat="#row.cells" as="cell" key="key" part="cell"><slot name="#cell.value"></slot></td>
72
+ </tr>
73
+ </tbody>
74
+ </table>
75
+ </custom-element>