@alacris/ui 0.0.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +148 -1
  3. package/package.json +75 -3
  4. package/src/components/base.js +46 -0
  5. package/src/components/ui-accordion-item.js +157 -0
  6. package/src/components/ui-accordion.js +58 -0
  7. package/src/components/ui-alert.js +168 -0
  8. package/src/components/ui-app-bar.js +119 -0
  9. package/src/components/ui-autocomplete.js +371 -0
  10. package/src/components/ui-avatar.js +104 -0
  11. package/src/components/ui-backdrop.js +66 -0
  12. package/src/components/ui-badge.js +95 -0
  13. package/src/components/ui-bottom-app-bar.js +78 -0
  14. package/src/components/ui-bottom-nav.js +83 -0
  15. package/src/components/ui-breadcrumbs.js +100 -0
  16. package/src/components/ui-button-group.js +49 -0
  17. package/src/components/ui-button.js +143 -0
  18. package/src/components/ui-card.js +86 -0
  19. package/src/components/ui-carousel-item.js +53 -0
  20. package/src/components/ui-carousel.js +301 -0
  21. package/src/components/ui-checkbox.js +165 -0
  22. package/src/components/ui-chip-set.js +90 -0
  23. package/src/components/ui-chip.js +226 -0
  24. package/src/components/ui-container.js +59 -0
  25. package/src/components/ui-date-picker.js +774 -0
  26. package/src/components/ui-dialog.js +179 -0
  27. package/src/components/ui-divider.js +58 -0
  28. package/src/components/ui-drawer.js +169 -0
  29. package/src/components/ui-fab-menu.js +122 -0
  30. package/src/components/ui-fab.js +117 -0
  31. package/src/components/ui-icon-button.js +112 -0
  32. package/src/components/ui-icon.js +64 -0
  33. package/src/components/ui-list-item.js +166 -0
  34. package/src/components/ui-list.js +38 -0
  35. package/src/components/ui-loading-indicator.js +74 -0
  36. package/src/components/ui-menu-item.js +117 -0
  37. package/src/components/ui-menu.js +192 -0
  38. package/src/components/ui-nav-item.js +138 -0
  39. package/src/components/ui-nav-rail.js +111 -0
  40. package/src/components/ui-option.js +85 -0
  41. package/src/components/ui-pagination.js +173 -0
  42. package/src/components/ui-progress.js +100 -0
  43. package/src/components/ui-radio-group.js +104 -0
  44. package/src/components/ui-radio.js +143 -0
  45. package/src/components/ui-rating.js +126 -0
  46. package/src/components/ui-search.js +318 -0
  47. package/src/components/ui-select.js +418 -0
  48. package/src/components/ui-sheet.js +205 -0
  49. package/src/components/ui-side-sheet.js +221 -0
  50. package/src/components/ui-skeleton.js +89 -0
  51. package/src/components/ui-slider.js +266 -0
  52. package/src/components/ui-snackbar.js +233 -0
  53. package/src/components/ui-spinner.js +101 -0
  54. package/src/components/ui-split-button.js +158 -0
  55. package/src/components/ui-stack.js +37 -0
  56. package/src/components/ui-step.js +91 -0
  57. package/src/components/ui-stepper.js +86 -0
  58. package/src/components/ui-surface.js +51 -0
  59. package/src/components/ui-switch.js +153 -0
  60. package/src/components/ui-tab-panel.js +54 -0
  61. package/src/components/ui-tab.js +116 -0
  62. package/src/components/ui-table-footer.js +141 -0
  63. package/src/components/ui-table-toolbar.js +219 -0
  64. package/src/components/ui-table.js +735 -0
  65. package/src/components/ui-tabs.js +178 -0
  66. package/src/components/ui-text-field.js +319 -0
  67. package/src/components/ui-text.js +36 -0
  68. package/src/components/ui-time-picker.js +753 -0
  69. package/src/components/ui-toggle-button.js +170 -0
  70. package/src/components/ui-toggle-group.js +111 -0
  71. package/src/components/ui-toolbar.js +64 -0
  72. package/src/components/ui-tooltip.js +148 -0
  73. package/src/index.js +121 -0
  74. package/src/motion/animate.js +145 -0
  75. package/src/motion/flip.js +46 -0
  76. package/src/motion/index.js +4 -0
  77. package/src/motion/presence.js +104 -0
  78. package/src/motion/ripple.js +108 -0
  79. package/src/theme/apply-theme.js +142 -0
  80. package/src/theme/create-theme.js +70 -0
  81. package/src/theme/index.js +5 -0
  82. package/src/tokens/color.js +237 -0
  83. package/src/tokens/index.js +14 -0
  84. package/src/tokens/sys.js +76 -0
  85. package/src/tokens/system.js +114 -0
  86. package/src/tokens/typography.js +189 -0
  87. package/src/util/focus.js +89 -0
  88. package/src/util/form.js +69 -0
  89. package/src/util/icons.js +161 -0
  90. package/src/util/keys.js +110 -0
  91. package/src/util/position.js +102 -0
  92. package/src/util/table.js +230 -0
  93. package/types/elements.d.ts +78 -0
  94. package/types/index.d.ts +168 -0
@@ -0,0 +1,53 @@
1
+ // <ui-carousel-item> — one slide inside <ui-carousel>.
2
+ //
3
+ // `selected` is written by the parent carousel; set the carousel's `index`
4
+ // instead of this prop. Width comes from `--ui-carousel-item-basis` on the
5
+ // parent (so the carousel variants can size slides without fighting `:host`).
6
+ // The last item snaps to the end of the track so it can be scrolled fully into
7
+ // view.
8
+ //
9
+ // @prop {boolean} selected=false — managed by the parent <ui-carousel>
10
+ // @slot (default) — slide content
11
+ // @part surface — the snap item
12
+ // @vars see `t` below (`themeVars.names`)
13
+
14
+ import { define, html, css, vars, effect } from '@alacris/core';
15
+ import { sys } from '../tokens/sys.js';
16
+ import { base } from './base.js';
17
+
18
+ const t = vars('ui-carousel-item', {
19
+ bg: sys.color.surfaceContainerHighest,
20
+ fg: sys.color.onSurface,
21
+ radius: sys.radius.xl,
22
+ });
23
+
24
+ const styles = css`
25
+ :host {
26
+ display: block;
27
+ flex: 0 0 var(--ui-carousel-item-basis, 40%);
28
+ scroll-snap-align: start;
29
+ min-inline-size: 0;
30
+ transition: flex-basis ${sys.duration.medium2} ${sys.easing.standard};
31
+ }
32
+ :host(:last-child) { scroll-snap-align: end; }
33
+ .surface {
34
+ block-size: 100%;
35
+ overflow: hidden;
36
+ background: ${t.bg};
37
+ color: ${t.fg};
38
+ border-radius: ${t.radius};
39
+ }
40
+ `;
41
+
42
+ define('ui-carousel-item', {
43
+ props: { selected: false },
44
+ styles: [base, styles],
45
+ setup({ selected }, host) {
46
+ effect(() => host.toggleAttribute('selected', selected()));
47
+ return html`<div class="surface" part="surface" role="group"
48
+ aria-roledescription="slide"><slot></slot></div>`;
49
+ },
50
+ });
51
+
52
+ export const tag = 'ui-carousel-item';
53
+ export const themeVars = t;
@@ -0,0 +1,301 @@
1
+ // <ui-carousel> — a Material carousel: snap-scrolling slides with previous /
2
+ // next controls.
3
+ //
4
+ // <ui-carousel label="Photos" variant="multi-browse">
5
+ // <ui-carousel-item>One</ui-carousel-item>
6
+ // <ui-carousel-item>Two</ui-carousel-item>
7
+ // </ui-carousel>
8
+ //
9
+ // multi-browse shows several items; uncontained lets slides overflow the
10
+ // frame; hero makes the selected slide dominate. Selection reflects down as
11
+ // `selected` on each <ui-carousel-item>. Prev/next (and arrow keys) scroll
12
+ // the selected slide into view; dragging the track updates `index`. The last
13
+ // slide snaps to the end of the viewport so a hero (or any oversized) last
14
+ // item can still become selected.
15
+ //
16
+ // @prop {number} index=0 — the selected slide
17
+ // @prop {string} variant='multi-browse' — multi-browse | uncontained | hero
18
+ // @prop {string} label='' — accessible name for the region
19
+ // @event change — index moved; detail: { index }
20
+ // @slot (default) — <ui-carousel-item> children
21
+ // @part viewport, track, prev, next
22
+ // @vars see `t` below (`themeVars.names`)
23
+
24
+ import { define, html, css, vars, effect, computed, signal, onCleanup } from '@alacris/core';
25
+ import { sys } from '../tokens/sys.js';
26
+ import { base, focusRingOn } from './base.js';
27
+ import { prefersReducedMotion } from '../motion/animate.js';
28
+ import './ui-icon-button.js';
29
+ import './ui-carousel-item.js';
30
+
31
+ const t = vars('ui-carousel', {
32
+ gap: sys.space(2),
33
+ height: '200px',
34
+ heroSize: '80%',
35
+ controlFg: sys.color.onSurface,
36
+ itemBasis: '40%',
37
+ });
38
+
39
+ const styles = css`
40
+ :host { display: block; position: relative; }
41
+ .root { position: relative; }
42
+ .viewport {
43
+ overflow-x: auto;
44
+ overflow-y: hidden;
45
+ scroll-snap-type: x mandatory;
46
+ scrollbar-width: none;
47
+ block-size: ${t.height};
48
+ --ui-carousel-item-basis: ${t.itemBasis};
49
+ }
50
+ .viewport::-webkit-scrollbar { display: none; }
51
+ .track {
52
+ display: flex;
53
+ align-items: stretch;
54
+ gap: ${t.gap};
55
+ block-size: 100%;
56
+ padding-inline: ${sys.space(2)};
57
+ }
58
+ .track > slot { display: contents; }
59
+ .uncontained { --ui-carousel-item-basis: 56%; }
60
+ .hero { --ui-carousel-item-basis: ${t.heroSize}; }
61
+ .hero ::slotted(ui-carousel-item) {
62
+ transition: opacity ${sys.duration.medium2} ${sys.easing.standard};
63
+ opacity: 0.75;
64
+ }
65
+ .hero ::slotted(ui-carousel-item[selected]) {
66
+ opacity: 1;
67
+ }
68
+ .prev, .next {
69
+ position: absolute;
70
+ inset-block-start: 50%;
71
+ translate: 0 -50%;
72
+ z-index: 1;
73
+ color: ${t.controlFg};
74
+ }
75
+ .prev { inset-inline-start: ${sys.space(1)}; }
76
+ .next { inset-inline-end: ${sys.space(1)}; }
77
+ ${focusRingOn('.prev')}
78
+ ${focusRingOn('.next')}
79
+ `;
80
+
81
+ define('ui-carousel', {
82
+ props: { index: 0, variant: 'multi-browse', label: '' },
83
+ styles: [base, styles],
84
+ setup({ index, variant, label }, host) {
85
+ const rev = signal(0);
86
+ const scrollPos = signal(0);
87
+ const maxScroll = signal(0);
88
+ const bump = () => rev.update((n) => n + 1);
89
+ const itemsOf = () => [...host.querySelectorAll('ui-carousel-item')];
90
+ let viewport = null;
91
+ let ignoreScroll = false;
92
+ let ignoreTimer = 0;
93
+ let ignoreGen = 0;
94
+
95
+ const clamp = (n) => {
96
+ const max = Math.max(0, itemsOf().length - 1);
97
+ return Math.max(0, Math.min(max, n));
98
+ };
99
+
100
+ const updateScrollBounds = () => {
101
+ if (!viewport) return;
102
+ scrollPos.set(viewport.scrollLeft);
103
+ maxScroll.set(Math.max(0, viewport.scrollWidth - viewport.clientWidth));
104
+ };
105
+
106
+ let isProgrammatic = true;
107
+
108
+ const beginIgnore = () => {
109
+ const gen = ++ignoreGen;
110
+ ignoreScroll = true;
111
+ if (ignoreTimer) clearTimeout(ignoreTimer);
112
+ const done = () => {
113
+ if (gen !== ignoreGen) return;
114
+ ignoreScroll = false;
115
+ ignoreTimer = 0;
116
+ updateScrollBounds();
117
+ };
118
+ viewport.addEventListener('scrollend', done, { once: true });
119
+ ignoreTimer = setTimeout(done, 500);
120
+ };
121
+
122
+ const targetScrollFor = (i) => {
123
+ const items = itemsOf();
124
+ if (!items.length || !viewport) return 0;
125
+ const idx = clamp(i);
126
+ const max = Math.max(0, viewport.scrollWidth - viewport.clientWidth);
127
+ if (idx === 0) return 0;
128
+ if (idx === items.length - 1 && items.length > 1) return max;
129
+
130
+ const track = viewport.querySelector('.track');
131
+ const trackPad = track ? parseFloat(getComputedStyle(track).paddingInlineStart) || 8 : 8;
132
+ const target = items[idx];
133
+ if (!target) return 0;
134
+ const dest = target.offsetLeft - trackPad;
135
+ return Math.max(0, Math.min(max, dest));
136
+ };
137
+
138
+ const go = (n) => {
139
+ const items = itemsOf();
140
+ if (!items.length) return;
141
+ const cur = index.peek();
142
+ const max = viewport ? Math.max(0, viewport.scrollWidth - viewport.clientWidth) : 0;
143
+ const hasLayout = viewport && viewport.clientWidth > 0 && max > 0;
144
+
145
+ let targetIdx = clamp(n);
146
+
147
+ if (hasLayout) {
148
+ const curScroll = viewport.scrollLeft;
149
+ if (n < cur) {
150
+ for (let i = cur - 1; i >= 0; i--) {
151
+ const dest = targetScrollFor(i);
152
+ if (dest < curScroll - 5 || i === 0) {
153
+ targetIdx = i;
154
+ break;
155
+ }
156
+ }
157
+ } else if (n > cur) {
158
+ for (let i = cur + 1; i < items.length; i++) {
159
+ const dest = targetScrollFor(i);
160
+ if (dest > curScroll + 5 || (i === items.length - 1 && curScroll < max - 5)) {
161
+ targetIdx = i;
162
+ break;
163
+ }
164
+ }
165
+ }
166
+ if (targetIdx === cur && Math.abs(targetScrollFor(targetIdx) - curScroll) < 2) return;
167
+ }
168
+
169
+ isProgrammatic = true;
170
+ index.set(targetIdx);
171
+ host.emit('change', { index: targetIdx });
172
+ };
173
+
174
+ const scrollToCurrent = () => {
175
+ const items = itemsOf();
176
+ const i = clamp(index.peek());
177
+ const target = items[i];
178
+ if (!target || !viewport) return;
179
+ if (typeof viewport.scrollTo !== 'function') return;
180
+ const dest = targetScrollFor(i);
181
+ if (Math.abs(dest - viewport.scrollLeft) < 1) {
182
+ updateScrollBounds();
183
+ return;
184
+ }
185
+ beginIgnore();
186
+ viewport.scrollTo({
187
+ left: dest,
188
+ behavior: prefersReducedMotion() ? 'auto' : 'smooth',
189
+ });
190
+ };
191
+
192
+ const sync = () => {
193
+ rev();
194
+ const i = clamp(index());
195
+ itemsOf().forEach((el, n) => { el.selected = n === i; });
196
+ if (isProgrammatic || !viewport) {
197
+ isProgrammatic = false;
198
+ requestAnimationFrame(scrollToCurrent);
199
+ }
200
+ };
201
+ effect(sync);
202
+
203
+ const onScroll = () => {
204
+ updateScrollBounds();
205
+ };
206
+
207
+ const onScrollEnd = () => {
208
+ updateScrollBounds();
209
+ if (ignoreScroll) return;
210
+ const items = itemsOf();
211
+ if (!items.length || !viewport) return;
212
+ const curScroll = viewport.scrollLeft;
213
+ const max = viewport.scrollWidth - viewport.clientWidth;
214
+
215
+ let best = 0;
216
+ if (max > 1 && curScroll >= max - 2) {
217
+ best = items.length - 1;
218
+ } else if (curScroll <= 2) {
219
+ best = 0;
220
+ } else {
221
+ const origin = viewport.getBoundingClientRect().left;
222
+ let bestDist = Infinity;
223
+ items.forEach((el, n) => {
224
+ const d = Math.abs(el.getBoundingClientRect().left - origin);
225
+ if (d < bestDist) { bestDist = d; best = n; }
226
+ });
227
+ }
228
+
229
+ if (best !== index.peek()) {
230
+ index.set(best);
231
+ host.emit('change', { index: best });
232
+ }
233
+ };
234
+
235
+ const onKey = (e) => {
236
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); go(index() + 1); }
237
+ else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); go(index() - 1); }
238
+ else if (e.key === 'Home') { e.preventDefault(); go(0); }
239
+ else if (e.key === 'End') { e.preventDefault(); go(itemsOf().length - 1); }
240
+ };
241
+
242
+ const viewportRef = (el) => {
243
+ viewport?.removeEventListener('scrollend', onScrollEnd);
244
+ viewport?.removeEventListener('scroll', onScroll);
245
+ viewport = el;
246
+ el.addEventListener('scrollend', onScrollEnd);
247
+ el.addEventListener('scroll', onScroll, { passive: true });
248
+ requestAnimationFrame(() => {
249
+ updateScrollBounds();
250
+ scrollToCurrent();
251
+ });
252
+ };
253
+ onCleanup(() => {
254
+ if (ignoreTimer) clearTimeout(ignoreTimer);
255
+ viewport?.removeEventListener('scrollend', onScrollEnd);
256
+ viewport?.removeEventListener('scroll', onScroll);
257
+ });
258
+
259
+ const cls = computed(() => `viewport ${variant()}`);
260
+ const atStart = computed(() => {
261
+ rev();
262
+ const i = index();
263
+ if (i <= 0) return true;
264
+ if (variant() !== 'hero' && viewport && viewport.clientWidth > 0) {
265
+ return scrollPos() <= 1 && i <= 0;
266
+ }
267
+ return i <= 0;
268
+ });
269
+ const atEnd = computed(() => {
270
+ rev();
271
+ const n = itemsOf().length;
272
+ if (n === 0) return true;
273
+ const i = index();
274
+ if (i >= n - 1) return true;
275
+ if (variant() !== 'hero' && viewport && viewport.clientWidth > 0) {
276
+ const max = maxScroll();
277
+ if (max > 1 && scrollPos() >= max - 2) return true;
278
+ }
279
+ return false;
280
+ });
281
+
282
+ return html`
283
+ <div class="root" role="region" aria-roledescription="carousel"
284
+ aria-label=${() => label() || null} @keydown=${onKey}>
285
+ <ui-icon-button class="prev" part="prev" variant="tonal"
286
+ icon="chevron-left" label="Previous"
287
+ ?disabled=${atStart} @click=${() => go(index() - 1)}></ui-icon-button>
288
+ <div class=${cls} part="viewport" tabindex="0" ref=${viewportRef}>
289
+ <div class="track" part="track">
290
+ <slot @slotchange=${bump}></slot>
291
+ </div>
292
+ </div>
293
+ <ui-icon-button class="next" part="next" variant="tonal"
294
+ icon="chevron-right" label="Next"
295
+ ?disabled=${atEnd} @click=${() => go(index() + 1)}></ui-icon-button>
296
+ </div>`;
297
+ },
298
+ });
299
+
300
+ export const tag = 'ui-carousel';
301
+ export const themeVars = t;
@@ -0,0 +1,165 @@
1
+ // <ui-checkbox> — the Material checkbox with indeterminate support.
2
+ //
3
+ // The interactive element is a native <button role="checkbox"> sized to a
4
+ // 40px touch target; the visible 18px box sits centered inside it. Clicking
5
+ // clears `indeterminate` and toggles `checked`. The check/dash is the MD3
6
+ // 2px stroke mark in the 18dp icon, not the generic 24dp `check` glyph.
7
+ //
8
+ // @prop {boolean} checked=false
9
+ // @prop {boolean} indeterminate=false — aria-checked="mixed", shows a dash
10
+ // @prop {boolean} disabled=false
11
+ // @prop {string} label='' — visible label; also the accessible name.
12
+ // Empty leaves the control unnamed (authoring error).
13
+ // @prop {string} name='' — form field name (submits `value` while checked)
14
+ // @prop {string} value='on'
15
+ // @event change — detail: { checked, indeterminate: false }
16
+ // @part control — the <button role="checkbox"> (the 40px target)
17
+ // @part box — the visible 18px box
18
+ // @part label — the visible label span (omit `label` and this is absent)
19
+ // @vars see `t` below (`themeVars.names`)
20
+
21
+ import { define, html, svg, css, vars, computed } from '@alacris/core';
22
+ import { sys } from '../tokens/sys.js';
23
+ import { base, focusRingOn } from './base.js';
24
+ import { ripple } from '../motion/ripple.js';
25
+ import { presence } from '../motion/presence.js';
26
+ import { fx } from '../motion/animate.js';
27
+ import { formBind } from '../util/form.js';
28
+
29
+ const t = vars('ui-checkbox', {
30
+ target: '40px',
31
+ boxSize: '18px',
32
+ radius: sys.radius.xs,
33
+ outlineColor: sys.color.onSurfaceVariant,
34
+ bg: sys.color.primary,
35
+ markFg: sys.color.onPrimary,
36
+ labelFg: sys.color.onSurface,
37
+ });
38
+
39
+ const styles = css`
40
+ :host { display: inline-flex; }
41
+ label {
42
+ display: inline-flex;
43
+ align-items: center;
44
+ cursor: pointer;
45
+ font: ${sys.type.bodyMd};
46
+ letter-spacing: ${sys.tracking.bodyMd};
47
+ color: ${t.labelFg};
48
+ user-select: none;
49
+ }
50
+ label.disabled {
51
+ cursor: default;
52
+ pointer-events: none;
53
+ color: color-mix(in srgb, ${sys.color.onSurface} calc(${sys.state.disabledContent} * 100%), transparent);
54
+ }
55
+ .control {
56
+ position: relative;
57
+ isolation: isolate;
58
+ flex: none;
59
+ display: grid;
60
+ place-items: center;
61
+ inline-size: max(${t.boxSize}, calc(${t.target} + var(--ui-density, 0) * 4px));
62
+ block-size: max(${t.boxSize}, calc(${t.target} + var(--ui-density, 0) * 4px));
63
+ padding: 0;
64
+ border: none;
65
+ border-radius: ${sys.radius.full};
66
+ background: transparent;
67
+ color: ${t.outlineColor};
68
+ cursor: pointer;
69
+ }
70
+ ${focusRingOn('.control')}
71
+ .layer {
72
+ position: absolute; inset: 0; z-index: -1;
73
+ border-radius: inherit;
74
+ background: currentColor;
75
+ opacity: 0;
76
+ transition: opacity ${sys.duration.short2} ${sys.easing.standard};
77
+ }
78
+ .control:hover .layer { opacity: ${sys.state.hover}; }
79
+ .control:focus-visible .layer { opacity: ${sys.state.focus}; }
80
+ .control:active .layer { opacity: ${sys.state.pressed}; }
81
+
82
+ .box {
83
+ position: relative;
84
+ inline-size: ${t.boxSize};
85
+ block-size: ${t.boxSize};
86
+ border-radius: ${t.radius};
87
+ display: grid;
88
+ place-items: center;
89
+ overflow: hidden;
90
+ color: ${t.markFg};
91
+ box-shadow: inset 0 0 0 2px ${t.outlineColor};
92
+ transition: background-color ${sys.duration.short2} ${sys.easing.standard},
93
+ box-shadow ${sys.duration.short2} ${sys.easing.standard};
94
+ }
95
+ .box svg {
96
+ display: block;
97
+ inline-size: ${t.boxSize};
98
+ block-size: ${t.boxSize};
99
+ }
100
+ .control[aria-checked="true"],
101
+ .control[aria-checked="mixed"] { color: ${t.bg}; }
102
+ .control[aria-checked="true"] .box,
103
+ .control[aria-checked="mixed"] .box {
104
+ background: ${t.bg};
105
+ box-shadow: none;
106
+ }
107
+
108
+ .control:disabled { cursor: default; pointer-events: none; }
109
+ .control:disabled .box {
110
+ box-shadow: inset 0 0 0 2px color-mix(in srgb, ${sys.color.onSurface} calc(${sys.state.disabledContent} * 100%), transparent);
111
+ }
112
+ .control:disabled[aria-checked="true"] .box,
113
+ .control:disabled[aria-checked="mixed"] .box {
114
+ background: color-mix(in srgb, ${sys.color.onSurface} calc(${sys.state.disabledContent} * 100%), transparent);
115
+ box-shadow: none;
116
+ color: ${sys.color.surface};
117
+ }
118
+ `;
119
+
120
+ define('ui-checkbox', {
121
+ formAssociated: true,
122
+ props: { checked: false, indeterminate: false, disabled: false, label: '', name: '', value: 'on' },
123
+ styles: [base, styles],
124
+ setup({ checked, indeterminate, disabled, label, name, value }, host) {
125
+ formBind(host, { name, value, checked, disabled });
126
+
127
+ const state = computed(() => (indeterminate() ? 'mixed' : String(checked())));
128
+ const marked = computed(() => checked() || indeterminate());
129
+
130
+ const onClick = () => {
131
+ if (disabled()) return;
132
+ indeterminate.set(false);
133
+ checked.set(!checked());
134
+ host.emit('change', { checked: checked(), indeterminate: false });
135
+ };
136
+
137
+ return html`
138
+ <label class=${() => (disabled() ? 'disabled' : null)}>
139
+ <button part="control" class="control" type="button" role="checkbox"
140
+ aria-checked=${state} ?disabled=${disabled}
141
+ aria-labelledby=${() => (label() ? 'label' : null)}
142
+ @click=${onClick}
143
+ ref=${(el) => ripple(el, { disabled, centered: true })}>
144
+ <span class="layer" aria-hidden="true"></span>
145
+ <span part="box" class="box" aria-hidden="true">
146
+ ${presence(marked, () => svg`
147
+ <svg viewBox="0 0 18 18" aria-hidden="true">
148
+ <path fill="none" stroke="currentColor" stroke-width="2"
149
+ stroke-linecap="round" stroke-linejoin="round"
150
+ d=${() => (indeterminate() ? 'M4 9h10' : 'M4 9.8l3.2 3.2 7.2-7.2')}></path>
151
+ </svg>`, {
152
+ enter: fx.scaleIn,
153
+ exit: fx.scaleOut,
154
+ enterDuration: 'short4',
155
+ exitDuration: 'short2',
156
+ })}
157
+ </span>
158
+ </button>
159
+ ${() => (label() ? html`<span id="label" part="label">${label}</span>` : null)}
160
+ </label>`;
161
+ },
162
+ });
163
+
164
+ export const tag = 'ui-checkbox';
165
+ export const themeVars = t;
@@ -0,0 +1,90 @@
1
+ // <ui-chip-set> — a wrapping row of <ui-chip>s with roving-tabindex focus.
2
+ //
3
+ // When any slotted chip is a filter chip the set is a listbox
4
+ // (aria-multiselectable mirrors `multi`); otherwise it is a plain group.
5
+ // Single-select coordination: with `multi=false`, selecting a filter chip
6
+ // deselects its siblings and the set emits `change` with the selected chip's
7
+ // `value`; deselecting the active chip emits `change` with ''.
8
+ //
9
+ // @prop {string} label='' — accessible name for the set
10
+ // @prop {boolean} multi=false — allow several filter chips selected at once
11
+ // @event change — single-select mode only; detail: { value }
12
+ // @slot (default) — <ui-chip> children
13
+ // @vars --ui-chip-set-gap
14
+
15
+ import { define, html, css, vars, signal, effect, onCleanup } from '@alacris/core';
16
+ import { sys } from '../tokens/sys.js';
17
+ import { base } from './base.js';
18
+ import { rovingTabindex } from '../util/keys.js';
19
+ import './ui-chip.js';
20
+
21
+ const t = vars('ui-chip-set', {
22
+ gap: sys.space(2),
23
+ });
24
+
25
+ const styles = css`
26
+ :host {
27
+ display: flex;
28
+ flex-wrap: wrap;
29
+ align-items: center;
30
+ gap: ${t.gap};
31
+ }
32
+ `;
33
+
34
+ define('ui-chip-set', {
35
+ props: { label: '', multi: false },
36
+ styles: [base, styles],
37
+ setup({ label, multi }, host) {
38
+ const hasFilter = signal(false);
39
+
40
+ effect(() => {
41
+ if (hasFilter()) {
42
+ host.setAttribute('role', 'listbox');
43
+ host.setAttribute('aria-multiselectable', String(multi()));
44
+ } else {
45
+ host.setAttribute('role', 'group');
46
+ host.removeAttribute('aria-multiselectable');
47
+ }
48
+ });
49
+ effect(() => {
50
+ if (label()) host.setAttribute('aria-label', label());
51
+ else host.removeAttribute('aria-label');
52
+ });
53
+
54
+ const roving = rovingTabindex(host, {
55
+ selector: 'ui-chip',
56
+ orientation: 'both',
57
+ skip: (el) => !!el.disabled || el.hasAttribute('disabled'),
58
+ });
59
+ onCleanup(() => roving.destroy());
60
+
61
+ const chips = () => [...host.querySelectorAll('ui-chip')];
62
+ const chipValue = (c) => {
63
+ const v = c.value;
64
+ return (v === undefined || v === null || v === '') ? (c.getAttribute('value') || '') : String(v);
65
+ };
66
+ const scan = () => {
67
+ hasFilter.set(chips().some((c) => (c.variant ?? c.getAttribute('variant')) === 'filter'));
68
+ roving.refresh();
69
+ };
70
+
71
+ // Single-select coordination over bubbling chip `change` events.
72
+ host.addEventListener('change', (e) => {
73
+ const chip = e.target;
74
+ if (!(chip instanceof Element) || chip.tagName !== 'UI-CHIP') return;
75
+ if (multi()) return;
76
+ e.stopPropagation();
77
+ if (e.detail?.selected) {
78
+ for (const c of chips()) if (c !== chip) c.selected = false;
79
+ host.emit('change', { value: chipValue(chip) });
80
+ } else {
81
+ host.emit('change', { value: '' });
82
+ }
83
+ });
84
+
85
+ return html`<slot ref=${(el) => { el.addEventListener('slotchange', scan); scan(); }}></slot>`;
86
+ },
87
+ });
88
+
89
+ export const tag = 'ui-chip-set';
90
+ export const themeVars = t;