@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,119 @@
1
+ // <ui-app-bar> — the Material top app bar.
2
+ //
3
+ // <ui-app-bar variant="small" scroll-elevate>
4
+ // <ui-icon-button slot="navigation" icon="menu" label="Menu"></ui-icon-button>
5
+ // Page title
6
+ // <ui-icon-button slot="actions" icon="search" label="Search"></ui-icon-button>
7
+ // </ui-app-bar>
8
+ //
9
+ // The host is position:sticky at the top. `elevated` forces the on-scroll
10
+ // container tint + shadow; `scrollElevate` listens to window scroll and
11
+ // applies it automatically once the page is scrolled (listener removed when
12
+ // the prop turns off or the element leaves the document).
13
+ //
14
+ // @prop {string} variant='small' — small | center | medium | large
15
+ // @prop {boolean} elevated=false — force the scrolled container/elevation
16
+ // @prop {boolean} scrollElevate=false — auto-elevate once window is scrolled
17
+ // @slot navigation — leading icon button
18
+ // @slot (default) — title text
19
+ // @slot actions — trailing icon buttons
20
+ // @part bar — the header surface
21
+ // @vars see `t` below (`themeVars.names`)
22
+
23
+ import { define, html, css, vars, signal, effect, computed } from '@alacris/core';
24
+ import { sys } from '../tokens/sys.js';
25
+ import { base } from './base.js';
26
+
27
+ const t = vars('ui-app-bar', {
28
+ bg: sys.color.surface,
29
+ scrolledBg: sys.color.surfaceContainer,
30
+ fg: sys.color.onSurface,
31
+ heightSmall: '64px',
32
+ heightMedium: '112px',
33
+ heightLarge: '152px',
34
+ font: sys.type.titleLg,
35
+ tracking: sys.tracking.titleLg,
36
+ });
37
+
38
+ const styles = css`
39
+ :host {
40
+ display: block;
41
+ position: sticky;
42
+ inset-block-start: 0;
43
+ z-index: ${sys.z.appBar};
44
+ }
45
+ .bar {
46
+ display: grid;
47
+ grid-template-columns: auto 1fr auto;
48
+ align-items: center;
49
+ column-gap: ${sys.space(1)};
50
+ padding-inline: ${sys.space(1)};
51
+ block-size: ${t.heightSmall};
52
+ background: ${t.bg};
53
+ color: ${t.fg};
54
+ box-shadow: none;
55
+ transition: background-color ${sys.duration.short4} ${sys.easing.standard},
56
+ box-shadow ${sys.duration.short4} ${sys.easing.standard};
57
+ }
58
+ .raised { background: ${t.scrolledBg}; box-shadow: ${sys.elevation[2]}; }
59
+ .title {
60
+ font: ${t.font};
61
+ letter-spacing: ${t.tracking};
62
+ padding-inline: ${sys.space(1)};
63
+ white-space: nowrap;
64
+ overflow: hidden;
65
+ text-overflow: ellipsis;
66
+ }
67
+ .center .title { text-align: center; }
68
+ .nav, .actions { display: inline-flex; align-items: center; gap: ${sys.space(1)}; }
69
+ .medium, .large {
70
+ grid-template-rows: ${t.heightSmall} 1fr;
71
+ grid-template-areas: 'nav . actions' 'title title title';
72
+ align-items: start;
73
+ }
74
+ .medium { block-size: ${t.heightMedium}; }
75
+ .large { block-size: ${t.heightLarge}; }
76
+ .medium .nav, .large .nav { grid-area: nav; align-self: center; }
77
+ .medium .actions, .large .actions { grid-area: actions; align-self: center; }
78
+ .medium .title, .large .title {
79
+ grid-area: title;
80
+ align-self: end;
81
+ padding-block-end: ${sys.space(4)};
82
+ padding-inline: ${sys.space(3)};
83
+ }
84
+ .medium .title { font: ${sys.type.headlineSm}; letter-spacing: ${sys.tracking.headlineSm}; }
85
+ .large .title { font: ${sys.type.headlineMd}; letter-spacing: ${sys.tracking.headlineMd}; }
86
+ `;
87
+
88
+ define('ui-app-bar', {
89
+ props: { variant: 'small', elevated: false, scrollElevate: false },
90
+ styles: [base, styles],
91
+ setup({ variant, elevated, scrollElevate }, host) {
92
+ const scrolled = signal(false);
93
+
94
+ effect(() => {
95
+ if (!scrollElevate()) {
96
+ scrolled.set(false);
97
+ return;
98
+ }
99
+ const onScroll = () => scrolled.set((window.scrollY || 0) > 0);
100
+ onScroll();
101
+ window.addEventListener('scroll', onScroll, { passive: true });
102
+ return () => window.removeEventListener('scroll', onScroll);
103
+ });
104
+
105
+ const cls = computed(
106
+ () => `bar ${variant()}${elevated() || scrolled() ? ' raised' : ''}`
107
+ );
108
+
109
+ return html`
110
+ <header class=${cls} part="bar">
111
+ <div class="nav"><slot name="navigation"></slot></div>
112
+ <div class="title"><slot></slot></div>
113
+ <div class="actions"><slot name="actions"></slot></div>
114
+ </header>`;
115
+ },
116
+ });
117
+
118
+ export const tag = 'ui-app-bar';
119
+ export const themeVars = t;
@@ -0,0 +1,371 @@
1
+ // <ui-autocomplete> — a combobox with a filtering text input.
2
+ //
3
+ // <ui-autocomplete label="Fruit" options=${['Apple', 'Banana']}
4
+ // @change=${(e) => pick(e.detail.value)}></ui-autocomplete>
5
+ //
6
+ // Typing filters the options case-insensitively and opens a listbox panel
7
+ // while there are matches. ArrowUp/Down move the active option, Enter commits
8
+ // the active option (or, with `freeSolo`, the raw text), Escape closes, blur
9
+ // commits an exact label match — or the raw text when `freeSolo` — and
10
+ // otherwise reverts to the last committed value.
11
+ //
12
+ // @prop {string} label=''
13
+ // @prop {string} value='' — the committed value
14
+ // @prop {Array} options=[] — strings or { value, label } objects
15
+ // (JSON attribute or property)
16
+ // @prop {string} variant='filled' — filled | outlined
17
+ // @prop {boolean} disabled=false
18
+ // @prop {boolean} required=false
19
+ // @prop {string} name='' — form participation
20
+ // @prop {string} placeholder=''
21
+ // @prop {boolean} freeSolo=false — allow values not present in options
22
+ // @event input — every keystroke; detail: { value } (the raw text)
23
+ // @event change — committed; detail: { value }
24
+ // @part field, input, label, panel
25
+ // @vars see `t` below (`themeVars.names`)
26
+
27
+ import { define, html, css, vars, computed, signal, effect, onCleanup, each } from '@alacris/core';
28
+ import { sys } from '../tokens/sys.js';
29
+ import { base } from './base.js';
30
+ import { formBind } from '../util/form.js';
31
+ import { presence } from '../motion/presence.js';
32
+ import { fx } from '../motion/animate.js';
33
+ import { autoUpdate } from '../util/position.js';
34
+ import './ui-icon.js';
35
+
36
+ const t = vars('ui-autocomplete', {
37
+ bg: sys.color.surfaceContainerHighest,
38
+ fg: sys.color.onSurface,
39
+ labelFg: sys.color.onSurfaceVariant,
40
+ accent: sys.color.primary,
41
+ outlineColor: sys.color.outline,
42
+ radius: sys.radius.xs,
43
+ font: sys.type.bodyLg,
44
+ height: '56px',
45
+ panelBg: sys.color.surfaceContainer,
46
+ });
47
+
48
+ const styles = css`
49
+ :host { display: block; inline-size: 240px; }
50
+ .root { display: block; position: relative; }
51
+ .field {
52
+ position: relative;
53
+ display: flex;
54
+ align-items: center;
55
+ gap: ${sys.space(2)};
56
+ min-block-size: calc(${t.height} + var(--ui-density, 0) * 4px);
57
+ padding-inline: ${sys.space(4)};
58
+ border-radius: ${t.radius};
59
+ font: ${t.font};
60
+ letter-spacing: ${sys.tracking.bodyLg};
61
+ color: ${t.fg};
62
+ cursor: text;
63
+ --ui-icon-size: 1.5rem;
64
+ }
65
+ .filled .field {
66
+ background: ${t.bg};
67
+ border-start-start-radius: ${t.radius};
68
+ border-start-end-radius: ${t.radius};
69
+ border-end-start-radius: 0;
70
+ border-end-end-radius: 0;
71
+ }
72
+ .filled .field::after {
73
+ content: '';
74
+ position: absolute;
75
+ inset-inline: 0;
76
+ inset-block-end: 0;
77
+ block-size: 1px;
78
+ background: ${sys.color.onSurfaceVariant};
79
+ transition: block-size ${sys.duration.short2} ${sys.easing.standard},
80
+ background-color ${sys.duration.short2} ${sys.easing.standard};
81
+ }
82
+ .filled.focused .field::after { block-size: 2px; background: ${t.accent}; }
83
+ .filled .field::before {
84
+ content: '';
85
+ position: absolute; inset: 0; pointer-events: none;
86
+ background: ${sys.color.onSurface};
87
+ opacity: 0;
88
+ border-radius: inherit;
89
+ transition: opacity ${sys.duration.short2} ${sys.easing.standard};
90
+ }
91
+ .filled:hover:not(.focused):not(.disabled) .field::before {
92
+ opacity: ${sys.state.hover};
93
+ }
94
+ .filled:hover:not(.focused):not(.disabled) .field::after {
95
+ background: ${sys.color.onSurface};
96
+ }
97
+
98
+ fieldset {
99
+ position: absolute;
100
+ inset: -6px 0 0;
101
+ margin: 0;
102
+ padding: 0 calc(${sys.space(3)} - 2px);
103
+ min-inline-size: 0;
104
+ box-sizing: border-box;
105
+ appearance: none;
106
+ border: 1px solid ${t.outlineColor};
107
+ border-radius: ${t.radius};
108
+ pointer-events: none;
109
+ transition: border-color ${sys.duration.short2} ${sys.easing.standard},
110
+ border-width ${sys.duration.short2} ${sys.easing.standard};
111
+ }
112
+ legend {
113
+ float: unset;
114
+ display: block;
115
+ width: max-content;
116
+ padding: 0;
117
+ margin: 0;
118
+ margin-inline-start: 0;
119
+ white-space: nowrap;
120
+ overflow: hidden;
121
+ font: ${t.font};
122
+ font-size: 0.75em;
123
+ letter-spacing: calc(${sys.tracking.bodyLg} * 0.75);
124
+ visibility: hidden;
125
+ max-inline-size: 0.01px;
126
+ height: 12px;
127
+ line-height: 12px;
128
+ transition: max-inline-size ${sys.duration.short2} ${sys.easing.standard};
129
+ }
130
+ legend span {
131
+ padding-inline: ${sys.space(1)} calc(${sys.space(1)} - 0.5px);
132
+ display: inline-block;
133
+ opacity: 0;
134
+ visibility: visible;
135
+ }
136
+ .outlined.floating legend {
137
+ max-inline-size: 100%;
138
+ transition: max-inline-size ${sys.duration.short3} ${sys.easing.standard};
139
+ }
140
+ .outlined.focused fieldset { border-width: 2px; border-color: ${t.accent}; }
141
+ .root:hover:not(.focused) fieldset { border-color: ${sys.color.onSurface}; }
142
+
143
+ .label {
144
+ position: absolute;
145
+ inset-inline-start: ${sys.space(4)};
146
+ inset-block-start: 50%;
147
+ translate: 0 -50%;
148
+ color: ${t.labelFg};
149
+ pointer-events: none;
150
+ transform-origin: 0 50%;
151
+ transition: translate ${sys.duration.short3} ${sys.easing.standard},
152
+ scale ${sys.duration.short3} ${sys.easing.standard},
153
+ color ${sys.duration.short2} ${sys.easing.standard};
154
+ }
155
+ .filled.floating .label { translate: 0 calc(-50% - 16px); scale: 0.75; }
156
+ .outlined.floating .label {
157
+ translate: 0 calc(-50% - (${t.height} + var(--ui-density, 0) * 4px) / 2);
158
+ scale: 0.75;
159
+ }
160
+ .focused .label { color: ${t.accent}; }
161
+
162
+ input {
163
+ flex: 1;
164
+ min-inline-size: 0;
165
+ margin: 0;
166
+ border: none;
167
+ outline: none;
168
+ appearance: none;
169
+ background: transparent;
170
+ font: inherit;
171
+ letter-spacing: inherit;
172
+ color: inherit;
173
+ padding: 0;
174
+ }
175
+ .filled.has-label input { padding-block-start: 18px; }
176
+ input::placeholder { color: ${t.labelFg}; opacity: 0; transition: opacity ${sys.duration.short2} linear; }
177
+ .floating input::placeholder { opacity: 1; }
178
+
179
+ .arrow {
180
+ color: ${t.labelFg};
181
+ transition: rotate ${sys.duration.short3} ${sys.easing.standard};
182
+ }
183
+ .expanded .arrow { rotate: 180deg; }
184
+
185
+ .panel {
186
+ position: fixed;
187
+ z-index: ${sys.z.modal};
188
+ min-inline-size: 112px;
189
+ max-block-size: 40vh;
190
+ overflow: auto;
191
+ padding-block: ${sys.space(2)};
192
+ background: ${t.panelBg};
193
+ border-radius: ${sys.radius.xs};
194
+ box-shadow: ${sys.elevation[2]};
195
+ transform-origin: top center;
196
+ }
197
+ .opt {
198
+ position: relative;
199
+ isolation: isolate;
200
+ display: flex;
201
+ align-items: center;
202
+ min-block-size: calc(48px + var(--ui-density, 0) * 4px);
203
+ padding-inline: ${sys.space(4)};
204
+ font: ${t.font};
205
+ letter-spacing: ${sys.tracking.bodyLg};
206
+ color: ${t.fg};
207
+ cursor: pointer;
208
+ user-select: none;
209
+ }
210
+ .opt .layer {
211
+ position: absolute; inset: 0; z-index: -1;
212
+ background: currentColor; opacity: 0;
213
+ transition: opacity ${sys.duration.short2} ${sys.easing.standard};
214
+ }
215
+ .opt:hover .layer { opacity: ${sys.state.hover}; }
216
+ .opt.active .layer { opacity: ${sys.state.focus}; }
217
+ .opt:active .layer { opacity: ${sys.state.pressed}; }
218
+ .opt[aria-selected="true"] { background: ${sys.color.secondaryContainer}; color: ${sys.color.onSecondaryContainer}; }
219
+
220
+ .disabled { opacity: ${sys.state.disabledContent}; pointer-events: none; }
221
+ `;
222
+
223
+ define('ui-autocomplete', {
224
+ formAssociated: true,
225
+ props: {
226
+ label: '', value: '', options: [], variant: 'filled', disabled: false,
227
+ required: false, name: '', placeholder: '', freeSolo: false,
228
+ },
229
+ styles: [base, styles],
230
+ setup(p, host) {
231
+ const { label, value, options, variant, disabled, required, name, placeholder, freeSolo } = p;
232
+ formBind(host, { name, value, disabled });
233
+
234
+ const query = signal('');
235
+ const focused = signal(false);
236
+ const open = signal(false);
237
+ const activeIndex = signal(-1);
238
+ let fieldEl = null;
239
+ let stopAuto = null;
240
+
241
+ const opts = computed(() => (options() || []).map((o) =>
242
+ typeof o === 'string'
243
+ ? { value: o, label: o }
244
+ : { value: String(o.value), label: o.label != null ? String(o.label) : String(o.value) }));
245
+
246
+ const labelFor = (v) => opts().find((o) => o.value === v)?.label ?? v;
247
+
248
+ // The committed value drives the visible text (also picks up label text
249
+ // arriving late through `options`).
250
+ effect(() => query.set(labelFor(value())));
251
+
252
+ const filtered = computed(() => {
253
+ const q = query().trim().toLowerCase();
254
+ return q ? opts().filter((o) => o.label.toLowerCase().includes(q)) : opts();
255
+ });
256
+ // New matches invalidate the keyboard-active option.
257
+ effect(() => { filtered(); activeIndex.set(-1); });
258
+
259
+ const activeOpt = computed(() => filtered()[activeIndex()] ?? null);
260
+ const showPanel = computed(() => open() && !disabled() && filtered().length > 0);
261
+
262
+ const floating = computed(() => focused() || query() !== '' || placeholder() !== '');
263
+ const cls = computed(() =>
264
+ ['root', variant(), floating() && 'floating', focused() && 'focused',
265
+ showPanel() && 'expanded', disabled() && 'disabled', label() && 'has-label']
266
+ .filter(Boolean).join(' '));
267
+
268
+ const commit = (v) => {
269
+ const text = labelFor(v);
270
+ if (v !== value()) {
271
+ value.set(v);
272
+ host.emit('change', { value: v });
273
+ }
274
+ query.set(text);
275
+ open.set(false);
276
+ };
277
+
278
+ const onInput = (e) => {
279
+ e.stopPropagation();
280
+ query.set(e.target.value);
281
+ open.set(true);
282
+ host.emit('input', { value: e.target.value });
283
+ };
284
+ const move = (delta) => {
285
+ const list = filtered();
286
+ if (!list.length) return;
287
+ const i = activeIndex();
288
+ activeIndex.set(i < 0 ? (delta > 0 ? 0 : list.length - 1) : (i + delta + list.length) % list.length);
289
+ };
290
+ const onKeydown = (e) => {
291
+ switch (e.key) {
292
+ case 'ArrowDown': e.preventDefault(); if (!open()) open.set(true); move(1); break;
293
+ case 'ArrowUp': e.preventDefault(); if (!open()) open.set(true); move(-1); break;
294
+ case 'Enter':
295
+ if (showPanel() && activeOpt()) { e.preventDefault(); commit(activeOpt().value); }
296
+ else if (freeSolo()) commit(query().trim());
297
+ break;
298
+ case 'Escape': open.set(false); break;
299
+ }
300
+ };
301
+ const onFocus = () => { focused.set(true); open.set(true); };
302
+ const onBlur = () => {
303
+ focused.set(false);
304
+ open.set(false);
305
+ const raw = query().trim();
306
+ if (freeSolo()) { commit(raw); return; }
307
+ const match = opts().find((o) => o.label.toLowerCase() === raw.toLowerCase());
308
+ if (match) commit(match.value);
309
+ else query.set(labelFor(value())); // revert to the committed value
310
+ };
311
+
312
+ effect(() => {
313
+ if (!showPanel() && stopAuto) { stopAuto(); stopAuto = null; }
314
+ });
315
+ onCleanup(() => stopAuto?.());
316
+
317
+ const panelRef = (el) => {
318
+ stopAuto?.();
319
+ stopAuto = autoUpdate(el, fieldEl, { placement: 'bottom-start', matchWidth: true, offset: 4 });
320
+ };
321
+ const panelView = () => html`
322
+ <div class="panel" part="panel" role="listbox" id="panel"
323
+ aria-label=${() => label() || null} ref=${panelRef}>
324
+ ${each(
325
+ () => filtered(),
326
+ (o) => html`
327
+ <div role="option"
328
+ class=${() => ({ opt: true, active: activeOpt()?.value === o().value })}
329
+ id=${() => (activeOpt()?.value === o().value ? 'ui-ac-active' : null)}
330
+ aria-selected=${() => String(value() === o().value)}
331
+ @pointerdown=${(e) => e.preventDefault()}
332
+ @click=${() => commit(o().value)}>
333
+ <span class="layer" aria-hidden="true"></span>
334
+ ${() => o().label}
335
+ </div>`,
336
+ (o) => o.value,
337
+ )}
338
+ </div>`;
339
+
340
+ return html`
341
+ <div class=${cls}>
342
+ <label class="field" part="field" ref=${(el) => (fieldEl = el)}>
343
+ ${() => (variant() === 'outlined'
344
+ ? html`<fieldset aria-hidden="true"><legend><span>${label}${() => (required() ? ' *' : '')}</span></legend></fieldset>`
345
+ : null)}
346
+ ${() => (label() ? html`<span class="label" part="label">${label}${() => (required() ? ' *' : '')}</span>` : null)}
347
+ <input part="input" role="combobox" aria-autocomplete="list"
348
+ aria-haspopup="listbox" aria-controls="panel"
349
+ aria-expanded=${() => String(showPanel())}
350
+ aria-activedescendant=${() => (showPanel() && activeOpt() ? 'ui-ac-active' : null)}
351
+ .value=${query}
352
+ placeholder=${() => placeholder() || null}
353
+ ?disabled=${disabled} ?required=${required}
354
+ @input=${onInput} @change=${(e) => e.stopPropagation()} @keydown=${onKeydown}
355
+ @focus=${onFocus} @blur=${onBlur}>
356
+ <ui-icon class="arrow" name="arrow-drop-down"></ui-icon>
357
+ </label>
358
+ ${presence(showPanel, panelView, {
359
+ enter: fx.scaleIn,
360
+ exit: fx.scaleOut,
361
+ enterDuration: 'short4',
362
+ exitDuration: 'short2',
363
+ enterEasing: 'emphasizedDecelerate',
364
+ exitEasing: 'emphasizedAccelerate',
365
+ })}
366
+ </div>`;
367
+ },
368
+ });
369
+
370
+ export const tag = 'ui-autocomplete';
371
+ export const themeVars = t;
@@ -0,0 +1,104 @@
1
+ // <ui-avatar> — a circular avatar: image, initials, or icon.
2
+ //
3
+ // Fallback chain: `src` image → initials from `name` (first + last word) →
4
+ // `icon` → slotted content. A broken image (error event) falls back to the
5
+ // initials automatically. Initials scale with the avatar (~40% of its size).
6
+ //
7
+ // @prop {string} src='' — image URL (object-fit: cover)
8
+ // @prop {string} name='' — person's name; drives initials and the aria-label fallback
9
+ // @prop {string} icon='' — registry icon shown when there is no image and no name
10
+ // @prop {string} size='' — CSS length overriding the 40px default (sets --ui-avatar-size)
11
+ // @prop {string} label='' — accessible name; falls back to `name`, else decorative
12
+ // @slot (default) — custom content when src/name/icon are all empty
13
+ // @vars --ui-avatar-size, --ui-avatar-bg, --ui-avatar-fg, --ui-avatar-radius
14
+
15
+ import { define, html, css, vars, computed, effect, signal } from '@alacris/core';
16
+ import { sys } from '../tokens/sys.js';
17
+ import { base } from './base.js';
18
+ import './ui-icon.js';
19
+
20
+ const t = vars('ui-avatar', {
21
+ size: '40px',
22
+ bg: sys.color.primaryContainer,
23
+ fg: sys.color.onPrimaryContainer,
24
+ radius: sys.radius.full,
25
+ });
26
+
27
+ const styles = css`
28
+ :host {
29
+ display: inline-flex;
30
+ align-items: center;
31
+ justify-content: center;
32
+ flex: none;
33
+ inline-size: ${t.size};
34
+ block-size: ${t.size};
35
+ border-radius: ${t.radius};
36
+ background: ${t.bg};
37
+ color: ${t.fg};
38
+ overflow: hidden;
39
+ vertical-align: middle;
40
+ user-select: none;
41
+ /* em-based scaling: initials are 1em of this. */
42
+ font-size: calc(${t.size} * 0.4);
43
+ --ui-icon-size: calc(${t.size} * 0.6);
44
+ }
45
+ img {
46
+ inline-size: 100%;
47
+ block-size: 100%;
48
+ object-fit: cover;
49
+ display: block;
50
+ }
51
+ .initials {
52
+ font: ${sys.type.titleMd};
53
+ font-size: 1em;
54
+ letter-spacing: normal;
55
+ line-height: 1;
56
+ }
57
+ `;
58
+
59
+ define('ui-avatar', {
60
+ props: { src: '', name: '', icon: '', size: '', label: '' },
61
+ styles: [base, styles],
62
+ setup({ src, name, icon, size, label }, host) {
63
+ const broken = signal(false);
64
+ effect(() => { src(); broken.set(false); }); // new source, new chance
65
+
66
+ const initials = computed(() => {
67
+ const words = name().trim().split(/\s+/).filter(Boolean);
68
+ if (!words.length) return '';
69
+ const first = words[0][0];
70
+ const last = words.length > 1 ? words[words.length - 1][0] : '';
71
+ return (first + last).toUpperCase();
72
+ });
73
+
74
+ effect(() => {
75
+ const l = label() || name();
76
+ if (l) {
77
+ host.setAttribute('role', 'img');
78
+ host.setAttribute('aria-label', l);
79
+ host.removeAttribute('aria-hidden');
80
+ } else {
81
+ host.removeAttribute('role');
82
+ host.removeAttribute('aria-label');
83
+ host.setAttribute('aria-hidden', 'true');
84
+ }
85
+ });
86
+
87
+ effect(() => {
88
+ if (size()) host.style.setProperty('--ui-avatar-size', size());
89
+ else host.style.removeProperty('--ui-avatar-size');
90
+ });
91
+
92
+ return html`${() => {
93
+ if (src() && !broken()) {
94
+ return html`<img src=${src} alt="" @error=${() => broken.set(true)}>`;
95
+ }
96
+ if (initials()) return html`<span class="initials" aria-hidden="true">${initials}</span>`;
97
+ if (icon()) return html`<ui-icon name=${icon}></ui-icon>`;
98
+ return html`<slot></slot>`;
99
+ }}`;
100
+ },
101
+ });
102
+
103
+ export const tag = 'ui-avatar';
104
+ export const themeVars = t;
@@ -0,0 +1,66 @@
1
+ // <ui-backdrop> — a full-screen scrim behind custom overlays.
2
+ //
3
+ // <ui-backdrop open=${open} @close=${() => open(false)}></ui-backdrop>
4
+ //
5
+ // Fades in/out, locks page scroll while open, and requests closing by
6
+ // emitting `close` when the scrim is clicked — the PARENT owns the state and
7
+ // flips the signal.
8
+ //
9
+ // @prop {boolean} open=false
10
+ // @prop {boolean} invisible=false — transparent scrim (still catches clicks)
11
+ // @event close — scrim clicked; detail: { reason: 'scrim' }
12
+ // @part scrim
13
+ // @vars see `t` below (`themeVars.names`)
14
+
15
+ import { define, html, css, vars, effect, onCleanup } from '@alacris/core';
16
+ import { sys } from '../tokens/sys.js';
17
+ import { base } from './base.js';
18
+ import { presence } from '../motion/presence.js';
19
+ import { fx } from '../motion/animate.js';
20
+ import { scrollLock } from '../util/focus.js';
21
+
22
+ const t = vars('ui-backdrop', {
23
+ bg: `color-mix(in srgb, ${sys.color.scrim} 32%, transparent)`,
24
+ });
25
+
26
+ const styles = css`
27
+ :host { display: contents; }
28
+ .scrim {
29
+ position: fixed;
30
+ inset: 0;
31
+ z-index: ${sys.z.modal};
32
+ background: ${t.bg};
33
+ }
34
+ .invisible { background: transparent; }
35
+ `;
36
+
37
+ define('ui-backdrop', {
38
+ props: { open: false, invisible: false },
39
+ styles: [base, styles],
40
+ setup({ open, invisible }, host) {
41
+ let unlock = null;
42
+ effect(() => {
43
+ if (open()) {
44
+ if (!unlock) unlock = scrollLock();
45
+ } else {
46
+ unlock?.();
47
+ unlock = null;
48
+ }
49
+ });
50
+ onCleanup(() => unlock?.());
51
+
52
+ const view = () => html`
53
+ <div part="scrim" aria-hidden="true"
54
+ class=${() => `scrim${invisible() ? ' invisible' : ''}`}
55
+ @click=${() => host.emit('close', { reason: 'scrim' })}></div>`;
56
+
57
+ return html`${presence(open, view, {
58
+ enter: fx.fadeIn,
59
+ exit: fx.fadeOut,
60
+ exitDuration: 'short3',
61
+ })}`;
62
+ },
63
+ });
64
+
65
+ export const tag = 'ui-backdrop';
66
+ export const themeVars = t;