@alacris/ui 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +316 -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,192 @@
1
+ // <ui-menu> — a menu anchored to a slotted trigger.
2
+ //
3
+ // <ui-menu placement="bottom-start" @select=${(e) => ...}>
4
+ // <ui-icon-button slot="anchor" icon="more-vert" label="More"></ui-icon-button>
5
+ // <ui-menu-item value="edit" icon="edit">Edit</ui-menu-item>
6
+ // <ui-menu-item value="delete" icon="delete" danger>Delete</ui-menu-item>
7
+ // </ui-menu>
8
+ //
9
+ // The "anchor" slot renders inline; clicking it toggles the menu. The panel is
10
+ // position:fixed, anchored to the slotted trigger, flips when it would
11
+ // overflow, and stays glued through scroll/resize. While open, focus moves to
12
+ // the first item and arrows rove vertically; Escape closes and refocuses the
13
+ // anchor, Tab and outside pointerdown close. Selecting an item emits `select`
14
+ // and closes.
15
+ //
16
+ // @prop {boolean} open=false
17
+ // @prop {string} placement='bottom-start' — side[-alignment] (see util/position.js)
18
+ // @event select — an item was chosen; detail: { value }
19
+ // @event open — panel visible (after the enter animation)
20
+ // @event close — panel removed (after the exit animation)
21
+ // @slot anchor — the trigger element
22
+ // @slot (default) — <ui-menu-item> children
23
+ // @part panel — the floating menu surface
24
+ // @vars see `t` below (`themeVars.names`)
25
+
26
+ import { define, html, css, vars, effect, onCleanup } from '@alacris/core';
27
+ import { sys } from '../tokens/sys.js';
28
+ import { base } from './base.js';
29
+ import { presence } from '../motion/presence.js';
30
+ import { fx } from '../motion/animate.js';
31
+ import { autoUpdate } from '../util/position.js';
32
+ import { rovingTabindex } from '../util/keys.js';
33
+ import './ui-menu-item.js';
34
+
35
+ const t = vars('ui-menu', {
36
+ bg: sys.color.surfaceContainer,
37
+ radius: sys.radius.xs,
38
+ minWidth: '112px',
39
+ maxWidth: '280px',
40
+ elevation: sys.elevation[2],
41
+ });
42
+
43
+ const styles = css`
44
+ :host { display: inline-block; }
45
+ .anchor { display: contents; }
46
+ .panel {
47
+ position: fixed;
48
+ inset-inline-start: 0;
49
+ inset-block-start: 0;
50
+ z-index: ${sys.z.modal};
51
+ min-inline-size: ${t.minWidth};
52
+ max-inline-size: ${t.maxWidth};
53
+ padding-block: ${sys.space(2)};
54
+ background: ${t.bg};
55
+ border-radius: ${t.radius};
56
+ box-shadow: ${t.elevation};
57
+ overflow-y: auto;
58
+ max-block-size: calc(100vh - 16px);
59
+ }
60
+ `;
61
+
62
+ // transform-origin on the panel's anchor-facing corner, per placement.
63
+ const originFor = (placement) => {
64
+ const [side, align = 'start'] = placement.split('-');
65
+ const cross = align === 'end' ? 'right' : align === 'center' ? 'center' : 'left';
66
+ if (side === 'top') return `bottom ${cross}`;
67
+ if (side === 'bottom') return `top ${cross}`;
68
+ const block = align === 'end' ? 'bottom' : align === 'center' ? 'center' : 'top';
69
+ return `${block} ${side === 'left' ? 'right' : 'left'}`;
70
+ };
71
+
72
+ define('ui-menu', {
73
+ props: { open: false, placement: 'bottom-start' },
74
+ styles: [base, styles],
75
+ setup({ open, placement }, host) {
76
+ let anchorSlot = null;
77
+ let stopPosition = null;
78
+ let roving = null;
79
+
80
+ const anchorEl = () => anchorSlot?.assignedElements()[0] || host;
81
+
82
+ const popupTrigger = (el) => {
83
+ if (!el || el === host) return null;
84
+ return el.shadowRoot?.querySelector('button, a[href], [role="button"]') || el;
85
+ };
86
+
87
+ const syncTrigger = () => {
88
+ const trigger = popupTrigger(anchorEl());
89
+ if (!trigger) return;
90
+ trigger.setAttribute('aria-haspopup', 'menu');
91
+ trigger.setAttribute('aria-expanded', open.peek() ? 'true' : 'false');
92
+ };
93
+
94
+ const close = () => {
95
+ if (!open.peek()) return;
96
+ open.set(false);
97
+ (popupTrigger(anchorEl()) || anchorEl()).focus?.();
98
+ };
99
+
100
+ host.addEventListener('keydown', (e) => {
101
+ if (!open.peek()) return;
102
+ if (e.key === 'Escape') {
103
+ e.stopPropagation();
104
+ close();
105
+ } else if (e.key === 'Tab') {
106
+ close();
107
+ }
108
+ });
109
+
110
+ host.addEventListener('ui-menu-select', (e) => {
111
+ e.stopPropagation();
112
+ host.emit('select', { value: e.detail.value });
113
+ close();
114
+ });
115
+
116
+ // Keyboard roving + outside-pointerdown dismissal, exactly while open.
117
+ // Items may be projected through a nested slot (ui-split-button), so
118
+ // collect from the flattened default slot and listen on document.
119
+ effect(() => {
120
+ syncTrigger();
121
+ if (!open()) return;
122
+ const menuItems = () => {
123
+ const slot = host.shadowRoot?.querySelector('.panel > slot');
124
+ return (slot?.assignedElements({ flatten: true }) || [])
125
+ .filter((el) => el.localName === 'ui-menu-item');
126
+ };
127
+ roving = rovingTabindex(host, {
128
+ items: menuItems,
129
+ listenOn: document,
130
+ orientation: 'vertical',
131
+ skip: (el) => el.disabled,
132
+ });
133
+ queueMicrotask(() => {
134
+ if (open.peek()) roving?.focus(0);
135
+ });
136
+ const onPointerDown = (e) => {
137
+ if (!e.composedPath().includes(host)) close();
138
+ };
139
+ const onDocKey = (e) => {
140
+ if (e.key === 'Escape') {
141
+ e.stopPropagation();
142
+ close();
143
+ } else if (e.key === 'Tab') {
144
+ close();
145
+ }
146
+ };
147
+ document.addEventListener('pointerdown', onPointerDown);
148
+ document.addEventListener('keydown', onDocKey);
149
+ return () => {
150
+ document.removeEventListener('pointerdown', onPointerDown);
151
+ document.removeEventListener('keydown', onDocKey);
152
+ roving?.destroy();
153
+ roving = null;
154
+ stopPosition?.();
155
+ stopPosition = null;
156
+ };
157
+ });
158
+ onCleanup(() => stopPosition?.());
159
+
160
+ const panelRef = (el) => {
161
+ el.style.transformOrigin = originFor(placement());
162
+ stopPosition = autoUpdate(el, anchorEl(), { placement: placement() });
163
+ };
164
+
165
+ const view = () => html`
166
+ <div class="panel" part="panel" role="menu" ref=${panelRef}>
167
+ <slot></slot>
168
+ </div>`;
169
+
170
+ return html`
171
+ <span class="anchor" @click=${() => open.set(!open())}>
172
+ <slot name="anchor" ref=${(el) => {
173
+ anchorSlot = el;
174
+ el.addEventListener('slotchange', syncTrigger);
175
+ syncTrigger();
176
+ }}></slot>
177
+ </span>
178
+ ${presence(open, view, {
179
+ enter: fx.scaleIn,
180
+ exit: fx.scaleOut,
181
+ enterDuration: 'short4',
182
+ exitDuration: 'short2',
183
+ enterEasing: 'emphasizedDecelerate',
184
+ exitEasing: 'emphasizedAccelerate',
185
+ onEntered: () => host.emit('open'),
186
+ onExited: () => host.emit('close'),
187
+ })}`;
188
+ },
189
+ });
190
+
191
+ export const tag = 'ui-menu';
192
+ export const themeVars = t;
@@ -0,0 +1,138 @@
1
+ // <ui-nav-item> — one destination inside <ui-bottom-nav> or <ui-nav-rail>.
2
+ //
3
+ // @prop {string} value='' — REQUIRED identity of the destination
4
+ // @prop {string} icon='' — registry icon name
5
+ // @prop {string} activeIcon='' — icon while selected (defaults to `icon`)
6
+ // @prop {string} label='' — visible label (and the accessible name)
7
+ // @prop {boolean} selected=false — managed by <ui-bottom-nav>
8
+ // @prop {boolean} disabled=false
9
+ // @event ui-nav-select — activated; detail: { value }
10
+ // @slot icon — custom icon content when `icon` is empty
11
+ // @part control — the <button>
12
+ // @part pill — the 56×32 icon container
13
+ // @vars see `t` below (`themeVars.names`)
14
+ //
15
+ // Focus: the host is the roving tab stop (<ui-bottom-nav> assigns tabindex);
16
+ // focus is forwarded to the inner button so Enter/Space activate natively.
17
+
18
+ import { define, html, css, vars, computed, onCleanup } from '@alacris/core';
19
+ import { sys } from '../tokens/sys.js';
20
+ import { base } from './base.js';
21
+ import { ripple } from '../motion/ripple.js';
22
+ import './ui-icon.js';
23
+
24
+ const t = vars('ui-nav-item', {
25
+ pillBg: sys.color.secondaryContainer,
26
+ iconFg: sys.color.onSurfaceVariant,
27
+ iconFgActive: sys.color.onSecondaryContainer,
28
+ labelFg: sys.color.onSurfaceVariant,
29
+ labelFgActive: sys.color.onSurface,
30
+ font: sys.type.labelMd,
31
+ tracking: sys.tracking.labelMd,
32
+ });
33
+
34
+ const styles = css`
35
+ :host { display: block; }
36
+ :host(:focus-visible) {
37
+ outline: ${sys.focus.ring};
38
+ outline-offset: calc(-1 * ${sys.focus.ringWidth});
39
+ border-radius: ${sys.radius.sm};
40
+ }
41
+ .control {
42
+ display: flex;
43
+ flex-direction: column;
44
+ align-items: center;
45
+ justify-content: center;
46
+ gap: ${sys.space(1)};
47
+ inline-size: 100%;
48
+ block-size: 100%;
49
+ min-inline-size: 48px;
50
+ padding-block: ${sys.space(3)};
51
+ border: none;
52
+ background: transparent;
53
+ cursor: pointer;
54
+ user-select: none;
55
+ outline: none;
56
+ color: ${t.iconFg};
57
+ }
58
+ .pill {
59
+ position: relative;
60
+ isolation: isolate;
61
+ display: grid;
62
+ place-items: center;
63
+ inline-size: 56px;
64
+ block-size: 32px;
65
+ border-radius: ${sys.radius.full};
66
+ transition: color ${sys.duration.short4} ${sys.easing.standard};
67
+ }
68
+ .bg {
69
+ position: absolute; inset: 0; z-index: -1;
70
+ border-radius: inherit;
71
+ background: ${t.pillBg};
72
+ opacity: 0;
73
+ scale: 0.6 1;
74
+ transition: opacity ${sys.duration.short4} ${sys.easing.standard},
75
+ scale ${sys.duration.medium2} ${sys.easing.emphasizedDecelerate};
76
+ }
77
+ .layer {
78
+ position: absolute; inset: 0; z-index: -1;
79
+ border-radius: inherit;
80
+ background: currentColor;
81
+ opacity: 0;
82
+ transition: opacity ${sys.duration.short2} ${sys.easing.standard};
83
+ }
84
+ .control:hover .layer { opacity: ${sys.state.hover}; }
85
+ .control:focus-visible .layer, :host(:focus-visible) .layer { opacity: ${sys.state.focus}; }
86
+ .control:active .layer { opacity: ${sys.state.pressed}; }
87
+ .selected .pill { color: ${t.iconFgActive}; }
88
+ .selected .bg { opacity: 1; scale: 1; }
89
+ .label {
90
+ font: ${t.font};
91
+ letter-spacing: ${t.tracking};
92
+ color: ${t.labelFg};
93
+ transition: color ${sys.duration.short4} ${sys.easing.standard};
94
+ }
95
+ .selected .label { color: ${t.labelFgActive}; }
96
+ .control:disabled {
97
+ cursor: default;
98
+ pointer-events: none;
99
+ color: color-mix(in srgb, ${sys.color.onSurface} calc(${sys.state.disabledContent} * 100%), transparent);
100
+ }
101
+ .control:disabled .label { color: inherit; }
102
+ `;
103
+
104
+ define('ui-nav-item', {
105
+ props: { value: '', icon: '', activeIcon: '', label: '', selected: false, disabled: false },
106
+ styles: [base, styles],
107
+ setup({ value, icon, activeIcon, label, selected, disabled }, host) {
108
+ let btn = null;
109
+ const forward = () => btn?.focus();
110
+ host.addEventListener('focus', forward);
111
+ onCleanup(() => host.removeEventListener('focus', forward));
112
+
113
+ const shownIcon = computed(() => (selected() && activeIcon()) || icon());
114
+
115
+ const onClick = () => {
116
+ if (disabled()) return;
117
+ host.emit('ui-nav-select', { value: value() });
118
+ };
119
+
120
+ return html`
121
+ <button part="control" class=${() => `control${selected() ? ' selected' : ''}`}
122
+ type="button" tabindex="-1" ?disabled=${disabled}
123
+ aria-label=${() => label() || value()}
124
+ aria-current=${() => (selected() ? 'page' : null)}
125
+ @click=${onClick}
126
+ ref=${(el) => (btn = el)}>
127
+ <span part="pill" class="pill" ref=${(el) => ripple(el, { disabled, centered: true })}>
128
+ <span class="bg" aria-hidden="true"></span>
129
+ <span class="layer" aria-hidden="true"></span>
130
+ ${() => (shownIcon() ? html`<ui-icon name=${shownIcon}></ui-icon>` : html`<slot name="icon"></slot>`)}
131
+ </span>
132
+ ${() => (label() ? html`<span class="label">${label}</span>` : null)}
133
+ </button>`;
134
+ },
135
+ });
136
+
137
+ export const tag = 'ui-nav-item';
138
+ export const themeVars = t;
@@ -0,0 +1,111 @@
1
+ // <ui-nav-rail> — the Material navigation rail.
2
+ //
3
+ // <ui-nav-rail value=${route} @change=${(e) => route(e.detail.value)}>
4
+ // <ui-fab slot="fab" icon="add"></ui-fab>
5
+ // <ui-nav-item value="home" icon="home" label="Home"></ui-nav-item>
6
+ // <ui-nav-item value="search" icon="search" label="Search"></ui-nav-item>
7
+ // </ui-nav-rail>
8
+ //
9
+ // A compact vertical destination list (the large-screen counterpart of
10
+ // <ui-bottom-nav>). Reuses <ui-nav-item>. The host flows with the page —
11
+ // pin it with position: sticky/fixed yourself.
12
+ //
13
+ // @prop {string} value='' — the selected item's `value`
14
+ // @prop {string} label='' — accessible name of the <nav>
15
+ // @prop {string} align='start' — start | center | end (where destinations sit)
16
+ // @event change — a destination was chosen; detail: { value }
17
+ // @slot menu — optional leading icon button (typically "menu")
18
+ // @slot fab — optional FAB above the destinations
19
+ // @slot (default) — <ui-nav-item> children
20
+ // @part rail — the <nav> container
21
+ // @vars see `t` below (`themeVars.names`)
22
+
23
+ import { define, html, css, vars, effect, onCleanup } from '@alacris/core';
24
+ import { sys } from '../tokens/sys.js';
25
+ import { base } from './base.js';
26
+ import { rovingTabindex } from '../util/keys.js';
27
+ import './ui-nav-item.js';
28
+
29
+ const t = vars('ui-nav-rail', {
30
+ width: '80px',
31
+ bg: sys.color.surface,
32
+ pad: sys.space(3),
33
+ });
34
+
35
+ const styles = css`
36
+ :host { display: block; inline-size: ${t.width}; block-size: 100%; }
37
+ .rail {
38
+ display: flex;
39
+ flex-direction: column;
40
+ align-items: center;
41
+ gap: ${sys.space(3)};
42
+ box-sizing: border-box;
43
+ inline-size: ${t.width};
44
+ min-block-size: 100%;
45
+ padding-block: ${t.pad};
46
+ background: ${t.bg};
47
+ }
48
+ .lead, .fab { display: flex; flex-direction: column; align-items: center; gap: ${sys.space(2)}; }
49
+ .lead:not(.has), .fab:not(.has) { display: none; }
50
+ .destinations {
51
+ display: flex;
52
+ flex-direction: column;
53
+ align-items: stretch;
54
+ gap: ${sys.space(1)};
55
+ inline-size: 100%;
56
+ flex: 1;
57
+ }
58
+ .destinations.start { justify-content: flex-start; }
59
+ .destinations.center { justify-content: center; }
60
+ .destinations.end { justify-content: flex-end; }
61
+ ::slotted(ui-nav-item) { inline-size: 100%; }
62
+ `;
63
+
64
+ define('ui-nav-rail', {
65
+ props: { value: '', label: '', align: 'start' },
66
+ styles: [base, styles],
67
+ setup({ value, label, align }, host) {
68
+ const roving = rovingTabindex(host, {
69
+ selector: 'ui-nav-item',
70
+ orientation: 'vertical',
71
+ skip: (el) => el.disabled,
72
+ });
73
+ onCleanup(() => roving.destroy());
74
+
75
+ const sync = () => {
76
+ for (const item of host.querySelectorAll('ui-nav-item')) {
77
+ item.selected = item.value === value();
78
+ }
79
+ roving.refresh();
80
+ };
81
+ effect(sync);
82
+
83
+ const onSelect = (e) => {
84
+ const v = e.detail?.value;
85
+ if (v === undefined || v === value()) return;
86
+ value.set(v);
87
+ host.emit('change', { value: v });
88
+ };
89
+ host.addEventListener('ui-nav-select', onSelect);
90
+ onCleanup(() => host.removeEventListener('ui-nav-select', onSelect));
91
+
92
+ const hasSlot = (el, set) =>
93
+ el.addEventListener('slotchange', () => set(el.assignedElements().length > 0));
94
+
95
+ return html`
96
+ <nav class="rail" part="rail" aria-label=${() => label() || null}>
97
+ <div class="lead" ref=${(el) => hasSlot(el.querySelector('slot'), (has) => el.classList.toggle('has', has))}>
98
+ <slot name="menu"></slot>
99
+ </div>
100
+ <div class="fab" ref=${(el) => hasSlot(el.querySelector('slot'), (has) => el.classList.toggle('has', has))}>
101
+ <slot name="fab"></slot>
102
+ </div>
103
+ <div class=${() => `destinations ${align() || 'start'}`}>
104
+ <slot @slotchange=${sync}></slot>
105
+ </div>
106
+ </nav>`;
107
+ },
108
+ });
109
+
110
+ export const tag = 'ui-nav-rail';
111
+ export const themeVars = t;
@@ -0,0 +1,85 @@
1
+ // <ui-option> — one choice inside <ui-select>.
2
+ //
3
+ // The host itself is the option (role="option"): it lives in the select's
4
+ // light DOM and is projected into the select's listbox panel. The select
5
+ // drives `selected`/`active` (via their attributes) and handles activation;
6
+ // the option only renders itself and its state layer.
7
+ //
8
+ // @prop {string} value='' — the value this option contributes (required)
9
+ // @prop {boolean} disabled=false
10
+ // @prop {boolean} selected=false — set by the owning select; mirrors aria-selected
11
+ // @prop {boolean} active=false — set by the owning select while keyboard-active
12
+ // @slot (default) — the visible label (also used by the select's field text)
13
+ // @part control — the option row
14
+ // @vars see `t` below (`themeVars.names`)
15
+
16
+ import { define, html, css, vars, effect } from '@alacris/core';
17
+ import { sys } from '../tokens/sys.js';
18
+ import { base } from './base.js';
19
+ import { ripple } from '../motion/ripple.js';
20
+
21
+ const t = vars('ui-option', {
22
+ fg: sys.color.onSurface,
23
+ selectedBg: sys.color.secondaryContainer,
24
+ selectedFg: sys.color.onSecondaryContainer,
25
+ font: sys.type.bodyLg,
26
+ height: '48px',
27
+ });
28
+
29
+ let uid = 0;
30
+
31
+ const styles = css`
32
+ :host { display: block; cursor: pointer; user-select: none; }
33
+ .control {
34
+ position: relative;
35
+ isolation: isolate;
36
+ display: flex;
37
+ align-items: center;
38
+ gap: ${sys.space(3)};
39
+ min-block-size: calc(${t.height} + var(--ui-density, 0) * 4px);
40
+ padding-inline: ${sys.space(4)};
41
+ font: ${t.font};
42
+ letter-spacing: ${sys.tracking.bodyLg};
43
+ color: ${t.fg};
44
+ }
45
+ .layer {
46
+ position: absolute; inset: 0; z-index: -1;
47
+ background: currentColor; opacity: 0;
48
+ transition: opacity ${sys.duration.short2} ${sys.easing.standard};
49
+ }
50
+ :host(:hover) .layer { opacity: ${sys.state.hover}; }
51
+ :host([data-active]) .layer { opacity: ${sys.state.focus}; }
52
+ :host(:active) .layer { opacity: ${sys.state.pressed}; }
53
+ :host([aria-selected="true"]) .control {
54
+ background: ${t.selectedBg};
55
+ color: ${t.selectedFg};
56
+ }
57
+ :host([aria-disabled="true"]) { pointer-events: none; cursor: default; }
58
+ :host([aria-disabled="true"]) .control {
59
+ color: color-mix(in srgb, ${sys.color.onSurface} calc(${sys.state.disabledContent} * 100%), transparent);
60
+ }
61
+ `;
62
+
63
+ define('ui-option', {
64
+ props: { value: '', disabled: false, selected: false, active: false },
65
+ styles: [base, styles],
66
+ setup({ disabled, selected, active }, host) {
67
+ if (!host.id) host.id = `ui-option-${++uid}`;
68
+ host.setAttribute('role', 'option');
69
+ effect(() => host.setAttribute('aria-selected', String(selected())));
70
+ effect(() => {
71
+ if (disabled()) host.setAttribute('aria-disabled', 'true');
72
+ else host.removeAttribute('aria-disabled');
73
+ });
74
+ effect(() => host.toggleAttribute('data-active', active()));
75
+
76
+ return html`
77
+ <div class="control" part="control" ref=${(el) => ripple(el, { disabled })}>
78
+ <span class="layer" aria-hidden="true"></span>
79
+ <slot></slot>
80
+ </div>`;
81
+ },
82
+ });
83
+
84
+ export const tag = 'ui-option';
85
+ export const themeVars = t;