@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.
- package/LICENSE +21 -0
- package/README.md +148 -1
- package/package.json +75 -3
- package/src/components/base.js +46 -0
- package/src/components/ui-accordion-item.js +157 -0
- package/src/components/ui-accordion.js +58 -0
- package/src/components/ui-alert.js +168 -0
- package/src/components/ui-app-bar.js +119 -0
- package/src/components/ui-autocomplete.js +371 -0
- package/src/components/ui-avatar.js +104 -0
- package/src/components/ui-backdrop.js +66 -0
- package/src/components/ui-badge.js +95 -0
- package/src/components/ui-bottom-app-bar.js +78 -0
- package/src/components/ui-bottom-nav.js +83 -0
- package/src/components/ui-breadcrumbs.js +100 -0
- package/src/components/ui-button-group.js +49 -0
- package/src/components/ui-button.js +143 -0
- package/src/components/ui-card.js +86 -0
- package/src/components/ui-carousel-item.js +53 -0
- package/src/components/ui-carousel.js +301 -0
- package/src/components/ui-checkbox.js +165 -0
- package/src/components/ui-chip-set.js +90 -0
- package/src/components/ui-chip.js +226 -0
- package/src/components/ui-container.js +59 -0
- package/src/components/ui-date-picker.js +774 -0
- package/src/components/ui-dialog.js +179 -0
- package/src/components/ui-divider.js +58 -0
- package/src/components/ui-drawer.js +169 -0
- package/src/components/ui-fab-menu.js +122 -0
- package/src/components/ui-fab.js +117 -0
- package/src/components/ui-icon-button.js +112 -0
- package/src/components/ui-icon.js +64 -0
- package/src/components/ui-list-item.js +166 -0
- package/src/components/ui-list.js +38 -0
- package/src/components/ui-loading-indicator.js +74 -0
- package/src/components/ui-menu-item.js +117 -0
- package/src/components/ui-menu.js +192 -0
- package/src/components/ui-nav-item.js +138 -0
- package/src/components/ui-nav-rail.js +111 -0
- package/src/components/ui-option.js +85 -0
- package/src/components/ui-pagination.js +173 -0
- package/src/components/ui-progress.js +100 -0
- package/src/components/ui-radio-group.js +104 -0
- package/src/components/ui-radio.js +143 -0
- package/src/components/ui-rating.js +126 -0
- package/src/components/ui-search.js +318 -0
- package/src/components/ui-select.js +418 -0
- package/src/components/ui-sheet.js +205 -0
- package/src/components/ui-side-sheet.js +221 -0
- package/src/components/ui-skeleton.js +89 -0
- package/src/components/ui-slider.js +266 -0
- package/src/components/ui-snackbar.js +233 -0
- package/src/components/ui-spinner.js +101 -0
- package/src/components/ui-split-button.js +158 -0
- package/src/components/ui-stack.js +37 -0
- package/src/components/ui-step.js +91 -0
- package/src/components/ui-stepper.js +86 -0
- package/src/components/ui-surface.js +51 -0
- package/src/components/ui-switch.js +153 -0
- package/src/components/ui-tab-panel.js +54 -0
- package/src/components/ui-tab.js +116 -0
- package/src/components/ui-table-footer.js +141 -0
- package/src/components/ui-table-toolbar.js +219 -0
- package/src/components/ui-table.js +735 -0
- package/src/components/ui-tabs.js +178 -0
- package/src/components/ui-text-field.js +319 -0
- package/src/components/ui-text.js +36 -0
- package/src/components/ui-time-picker.js +753 -0
- package/src/components/ui-toggle-button.js +170 -0
- package/src/components/ui-toggle-group.js +111 -0
- package/src/components/ui-toolbar.js +64 -0
- package/src/components/ui-tooltip.js +148 -0
- package/src/index.js +121 -0
- package/src/motion/animate.js +145 -0
- package/src/motion/flip.js +46 -0
- package/src/motion/index.js +4 -0
- package/src/motion/presence.js +104 -0
- package/src/motion/ripple.js +108 -0
- package/src/theme/apply-theme.js +142 -0
- package/src/theme/create-theme.js +70 -0
- package/src/theme/index.js +5 -0
- package/src/tokens/color.js +237 -0
- package/src/tokens/index.js +14 -0
- package/src/tokens/sys.js +76 -0
- package/src/tokens/system.js +114 -0
- package/src/tokens/typography.js +189 -0
- package/src/util/focus.js +89 -0
- package/src/util/form.js +69 -0
- package/src/util/icons.js +161 -0
- package/src/util/keys.js +110 -0
- package/src/util/position.js +102 -0
- package/src/util/table.js +230 -0
- package/types/elements.d.ts +78 -0
- package/types/index.d.ts +168 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// <ui-pagination> — page navigation with sibling/boundary windows.
|
|
2
|
+
//
|
|
3
|
+
// <ui-pagination count="10" page=${page} @change=${(e) => page(e.detail.page)}>
|
|
4
|
+
// </ui-pagination>
|
|
5
|
+
//
|
|
6
|
+
// @prop {number} page=1 — current page (1-based)
|
|
7
|
+
// @prop {number} count=1 — total pages
|
|
8
|
+
// @prop {number} siblings=1 — pages shown on each side of the current page
|
|
9
|
+
// @prop {number} boundaries=1 — pages always shown at each end
|
|
10
|
+
// @prop {boolean} disabled=false
|
|
11
|
+
// @prop {string} label='Pagination' — accessible name of the <nav>
|
|
12
|
+
// @event change — a page was chosen (numbers, prev, next); detail: { page }
|
|
13
|
+
// @part nav, list
|
|
14
|
+
// @vars see `t` below (`themeVars.names`)
|
|
15
|
+
|
|
16
|
+
import { define, html, css, vars, computed } from '@alacris/core';
|
|
17
|
+
import { sys } from '../tokens/sys.js';
|
|
18
|
+
import { base, focusRingOn } from './base.js';
|
|
19
|
+
import { ripple } from '../motion/ripple.js';
|
|
20
|
+
import './ui-icon.js';
|
|
21
|
+
|
|
22
|
+
const t = vars('ui-pagination', {
|
|
23
|
+
size: '40px',
|
|
24
|
+
radius: sys.radius.full,
|
|
25
|
+
fg: sys.color.onSurfaceVariant,
|
|
26
|
+
currentBg: sys.color.primary,
|
|
27
|
+
currentFg: sys.color.onPrimary,
|
|
28
|
+
font: sys.type.labelLg,
|
|
29
|
+
tracking: sys.tracking.labelLg,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const styles = css`
|
|
33
|
+
:host { display: block; }
|
|
34
|
+
.list {
|
|
35
|
+
display: flex;
|
|
36
|
+
align-items: center;
|
|
37
|
+
flex-wrap: wrap;
|
|
38
|
+
gap: ${sys.space(1)};
|
|
39
|
+
}
|
|
40
|
+
.item {
|
|
41
|
+
display: inline-flex;
|
|
42
|
+
align-items: center;
|
|
43
|
+
justify-content: center;
|
|
44
|
+
min-inline-size: calc(${t.size} + var(--ui-density, 0) * 4px);
|
|
45
|
+
block-size: calc(${t.size} + var(--ui-density, 0) * 4px);
|
|
46
|
+
border-radius: ${t.radius};
|
|
47
|
+
font: ${t.font};
|
|
48
|
+
letter-spacing: ${t.tracking};
|
|
49
|
+
color: ${t.fg};
|
|
50
|
+
}
|
|
51
|
+
button.item {
|
|
52
|
+
position: relative;
|
|
53
|
+
isolation: isolate;
|
|
54
|
+
border: none;
|
|
55
|
+
background: transparent;
|
|
56
|
+
padding: 0;
|
|
57
|
+
cursor: pointer;
|
|
58
|
+
user-select: none;
|
|
59
|
+
--ui-icon-size: 1.25rem;
|
|
60
|
+
}
|
|
61
|
+
${focusRingOn('button.item')}
|
|
62
|
+
.layer {
|
|
63
|
+
position: absolute; inset: 0; z-index: -1;
|
|
64
|
+
border-radius: inherit;
|
|
65
|
+
background: currentColor;
|
|
66
|
+
opacity: 0;
|
|
67
|
+
transition: opacity ${sys.duration.short2} ${sys.easing.standard};
|
|
68
|
+
}
|
|
69
|
+
button.item:hover .layer { opacity: ${sys.state.hover}; }
|
|
70
|
+
button.item:focus-visible .layer { opacity: ${sys.state.focus}; }
|
|
71
|
+
button.item:active .layer { opacity: ${sys.state.pressed}; }
|
|
72
|
+
button.item.current { background: ${t.currentBg}; color: ${t.currentFg}; }
|
|
73
|
+
button.item:disabled {
|
|
74
|
+
cursor: default;
|
|
75
|
+
pointer-events: none;
|
|
76
|
+
color: color-mix(in srgb, ${sys.color.onSurface} calc(${sys.state.disabledContent} * 100%), transparent);
|
|
77
|
+
}
|
|
78
|
+
button.item.current:disabled {
|
|
79
|
+
background: color-mix(in srgb, ${sys.color.onSurface} calc(${sys.state.disabledContainer} * 100%), transparent);
|
|
80
|
+
}
|
|
81
|
+
`;
|
|
82
|
+
|
|
83
|
+
const range = (start, end) => {
|
|
84
|
+
const out = [];
|
|
85
|
+
for (let i = start; i <= end; i++) out.push(i);
|
|
86
|
+
return out;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
define('ui-pagination', {
|
|
90
|
+
props: { page: 1, count: 1, siblings: 1, boundaries: 1, disabled: false, label: 'Pagination' },
|
|
91
|
+
styles: [base, styles],
|
|
92
|
+
setup({ page, count, siblings, boundaries, disabled, label }, host) {
|
|
93
|
+
const total = () => Math.max(1, Math.floor(count()) || 1);
|
|
94
|
+
const current = () => Math.min(Math.max(1, Math.floor(page()) || 1), total());
|
|
95
|
+
|
|
96
|
+
// The MUI usePagination windowing: boundary pages at both ends, a sibling
|
|
97
|
+
// window around the current page, and ellipses where the gaps are >1.
|
|
98
|
+
const items = computed(() => {
|
|
99
|
+
const c = total();
|
|
100
|
+
const p = current();
|
|
101
|
+
const sib = Math.max(0, siblings());
|
|
102
|
+
const bnd = Math.max(0, boundaries());
|
|
103
|
+
|
|
104
|
+
const startPages = range(1, Math.min(bnd, c));
|
|
105
|
+
const endPages = range(Math.max(c - bnd + 1, bnd + 1), c);
|
|
106
|
+
|
|
107
|
+
const siblingsStart = Math.max(Math.min(p - sib, c - bnd - sib * 2 - 1), bnd + 2);
|
|
108
|
+
const siblingsEnd = Math.min(
|
|
109
|
+
Math.max(p + sib, bnd + sib * 2 + 2),
|
|
110
|
+
endPages.length > 0 ? endPages[0] - 2 : c - 1,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
return [
|
|
114
|
+
'prev',
|
|
115
|
+
...startPages,
|
|
116
|
+
...(siblingsStart > bnd + 2 ? ['start-ellipsis'] : bnd + 1 < c - bnd ? [bnd + 1] : []),
|
|
117
|
+
...range(siblingsStart, siblingsEnd),
|
|
118
|
+
...(siblingsEnd < c - bnd - 1 ? ['end-ellipsis'] : c - bnd > bnd ? [c - bnd] : []),
|
|
119
|
+
...endPages,
|
|
120
|
+
'next',
|
|
121
|
+
];
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const setPage = (n) => {
|
|
125
|
+
const clamped = Math.min(Math.max(1, n), total());
|
|
126
|
+
if (clamped === current()) return;
|
|
127
|
+
page.set(clamped);
|
|
128
|
+
host.emit('change', { page: clamped });
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
return html`
|
|
132
|
+
<nav part="nav" aria-label=${label}>
|
|
133
|
+
<div class="list" part="list">
|
|
134
|
+
${() => {
|
|
135
|
+
const dis = disabled();
|
|
136
|
+
const p = current();
|
|
137
|
+
const c = total();
|
|
138
|
+
return items().map((it) => {
|
|
139
|
+
if (it === 'prev' || it === 'next') {
|
|
140
|
+
const next = it === 'next';
|
|
141
|
+
return html`
|
|
142
|
+
<button type="button" class="item"
|
|
143
|
+
aria-label=${next ? 'Next page' : 'Previous page'}
|
|
144
|
+
?disabled=${dis || (next ? p >= c : p <= 1)}
|
|
145
|
+
@click=${() => setPage(next ? current() + 1 : current() - 1)}
|
|
146
|
+
ref=${(el) => ripple(el, { disabled, centered: true })}>
|
|
147
|
+
<span class="layer" aria-hidden="true"></span>
|
|
148
|
+
<ui-icon name=${next ? 'chevron-right' : 'chevron-left'}></ui-icon>
|
|
149
|
+
</button>`;
|
|
150
|
+
}
|
|
151
|
+
if (typeof it === 'string') {
|
|
152
|
+
return html`<span class="item ellipsis" aria-hidden="true">…</span>`;
|
|
153
|
+
}
|
|
154
|
+
const isCurrent = it === p;
|
|
155
|
+
return html`
|
|
156
|
+
<button type="button" class=${isCurrent ? 'item current' : 'item'}
|
|
157
|
+
aria-label=${`Page ${it}`}
|
|
158
|
+
aria-current=${isCurrent ? 'page' : null}
|
|
159
|
+
?disabled=${dis}
|
|
160
|
+
@click=${() => setPage(it)}
|
|
161
|
+
ref=${(el) => ripple(el, { disabled, centered: true })}>
|
|
162
|
+
<span class="layer" aria-hidden="true"></span>
|
|
163
|
+
${it}
|
|
164
|
+
</button>`;
|
|
165
|
+
});
|
|
166
|
+
}}
|
|
167
|
+
</div>
|
|
168
|
+
</nav>`;
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
export const tag = 'ui-pagination';
|
|
173
|
+
export const themeVars = t;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// <ui-progress> — Material linear progress.
|
|
2
|
+
//
|
|
3
|
+
// <ui-progress label="Upload" value=${pct}></ui-progress> determinate
|
|
4
|
+
// <ui-progress label="Loading"></ui-progress> indeterminate
|
|
5
|
+
//
|
|
6
|
+
// @prop {number} value=-1 — current value; -1 (any negative) = indeterminate
|
|
7
|
+
// @prop {number} max=100 — value scale
|
|
8
|
+
// @prop {string} label='' — accessible name (aria-label on the progressbar)
|
|
9
|
+
// @part track — the rail
|
|
10
|
+
// @part bar — the active indicator
|
|
11
|
+
// @vars see `t` below (`themeVars.names`)
|
|
12
|
+
//
|
|
13
|
+
// The determinate width rides a custom property bound from the template
|
|
14
|
+
// (`--ui-progress-pct`), so a value change is one property write. The
|
|
15
|
+
// indeterminate mode is the Material two-bar translate/scale loop, written as
|
|
16
|
+
// CSS keyframes; its cycle length derives from the motion tokens so a theme's
|
|
17
|
+
// motion scale slows or stops it with everything else.
|
|
18
|
+
|
|
19
|
+
import { define, html, css, vars, computed } from '@alacris/core';
|
|
20
|
+
import { sys } from '../tokens/sys.js';
|
|
21
|
+
import { base } from './base.js';
|
|
22
|
+
|
|
23
|
+
const t = vars('ui-progress', {
|
|
24
|
+
height: '4px',
|
|
25
|
+
radius: sys.radius.full,
|
|
26
|
+
trackBg: sys.color.surfaceContainerHighest,
|
|
27
|
+
barBg: sys.color.primary,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const styles = css`
|
|
31
|
+
:host { display: block; inline-size: 100%; }
|
|
32
|
+
.track {
|
|
33
|
+
position: relative;
|
|
34
|
+
block-size: ${t.height};
|
|
35
|
+
background: ${t.trackBg};
|
|
36
|
+
border-radius: ${t.radius};
|
|
37
|
+
overflow: hidden;
|
|
38
|
+
}
|
|
39
|
+
.bar {
|
|
40
|
+
position: absolute;
|
|
41
|
+
inset-block: 0;
|
|
42
|
+
inset-inline-start: 0;
|
|
43
|
+
inline-size: 100%;
|
|
44
|
+
background: ${t.barBg};
|
|
45
|
+
border-radius: inherit;
|
|
46
|
+
}
|
|
47
|
+
.track:not(.indeterminate) .bar1 {
|
|
48
|
+
inline-size: var(--ui-progress-pct, 0%);
|
|
49
|
+
transition: inline-size ${sys.duration.short4} ${sys.easing.standard};
|
|
50
|
+
}
|
|
51
|
+
.bar2 { display: none; }
|
|
52
|
+
|
|
53
|
+
@keyframes ui-progress-i1 {
|
|
54
|
+
0% { transform: translateX(-100%) scaleX(0.5); }
|
|
55
|
+
30% { transform: translateX(-10%) scaleX(0.7); }
|
|
56
|
+
60% { transform: translateX(100%) scaleX(0.35); }
|
|
57
|
+
100% { transform: translateX(100%) scaleX(0.35); }
|
|
58
|
+
}
|
|
59
|
+
@keyframes ui-progress-i2 {
|
|
60
|
+
0% { transform: translateX(-100%) scaleX(0.6); }
|
|
61
|
+
40% { transform: translateX(-100%) scaleX(0.6); }
|
|
62
|
+
70% { transform: translateX(15%) scaleX(0.5); }
|
|
63
|
+
100% { transform: translateX(101%) scaleX(0.2); }
|
|
64
|
+
}
|
|
65
|
+
.indeterminate .bar { transform: translateX(-100%); transform-origin: left center; }
|
|
66
|
+
.indeterminate .bar1 {
|
|
67
|
+
animation: ui-progress-i1 calc(${sys.duration.extraLong4} * 2) ${sys.easing.linear} infinite;
|
|
68
|
+
}
|
|
69
|
+
.indeterminate .bar2 {
|
|
70
|
+
display: block;
|
|
71
|
+
animation: ui-progress-i2 calc(${sys.duration.extraLong4} * 2) ${sys.easing.linear} infinite;
|
|
72
|
+
}
|
|
73
|
+
`;
|
|
74
|
+
|
|
75
|
+
define('ui-progress', {
|
|
76
|
+
props: { value: -1, max: 100, label: '' },
|
|
77
|
+
styles: [base, styles],
|
|
78
|
+
setup({ value, max, label }) {
|
|
79
|
+
const indeterminate = computed(() => value() < 0);
|
|
80
|
+
const clamped = computed(() => Math.min(Math.max(value(), 0), max() || 100));
|
|
81
|
+
// Rounded so float noise (55.00000000000001) never reaches the style.
|
|
82
|
+
const pct = computed(() => Math.round((clamped() / (max() || 100)) * 10000) / 100);
|
|
83
|
+
|
|
84
|
+
return html`
|
|
85
|
+
<div part="track"
|
|
86
|
+
class=${() => `track${indeterminate() ? ' indeterminate' : ''}`}
|
|
87
|
+
role="progressbar"
|
|
88
|
+
aria-label=${() => label() || null}
|
|
89
|
+
aria-valuemin="0"
|
|
90
|
+
aria-valuemax=${max}
|
|
91
|
+
aria-valuenow=${() => (indeterminate() ? null : String(clamped()))}
|
|
92
|
+
style=${() => (indeterminate() ? null : { '--ui-progress-pct': pct() + '%' })}>
|
|
93
|
+
<div class="bar bar1" part="bar"></div>
|
|
94
|
+
<div class="bar bar2" aria-hidden="true"></div>
|
|
95
|
+
</div>`;
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
export const tag = 'ui-progress';
|
|
100
|
+
export const themeVars = t;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// <ui-radio-group> — owns a set of slotted <ui-radio>s: one selected value,
|
|
2
|
+
// one tab stop, arrow keys move AND select (ARIA APG radio group pattern).
|
|
3
|
+
//
|
|
4
|
+
// <ui-radio-group name="size" value=${size} @change=${(e) => size.set(e.detail.value)}>
|
|
5
|
+
// <ui-radio value="s" label="Small"></ui-radio>
|
|
6
|
+
// <ui-radio value="m" label="Medium"></ui-radio>
|
|
7
|
+
// </ui-radio-group>
|
|
8
|
+
//
|
|
9
|
+
// @prop {string} value=''
|
|
10
|
+
// @prop {string} name='' — form participation (submits `value`)
|
|
11
|
+
// @prop {string} label='' — accessible name for the group
|
|
12
|
+
// @prop {boolean} disabled=false — disables every radio
|
|
13
|
+
// @prop {string} orientation='vertical' — vertical | horizontal (layout only;
|
|
14
|
+
// arrows work on both axes per the APG)
|
|
15
|
+
// @event change — detail: { value }
|
|
16
|
+
// @slot (default) — the <ui-radio> children
|
|
17
|
+
// @part group — the layout container
|
|
18
|
+
// @vars see `t` below (`themeVars.names`)
|
|
19
|
+
|
|
20
|
+
import { define, html, css, vars, effect, onCleanup, untrack } from '@alacris/core';
|
|
21
|
+
import { sys } from '../tokens/sys.js';
|
|
22
|
+
import { base } from './base.js';
|
|
23
|
+
import { rovingTabindex } from '../util/keys.js';
|
|
24
|
+
import { formBind } from '../util/form.js';
|
|
25
|
+
import './ui-radio.js';
|
|
26
|
+
|
|
27
|
+
const t = vars('ui-radio-group', {
|
|
28
|
+
gap: sys.space(1),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const styles = css`
|
|
32
|
+
:host { display: inline-flex; }
|
|
33
|
+
.group { display: flex; flex-direction: column; gap: ${t.gap}; }
|
|
34
|
+
:host([orientation="horizontal"]) .group { flex-direction: row; gap: ${sys.space(4)}; }
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
define('ui-radio-group', {
|
|
38
|
+
formAssociated: true,
|
|
39
|
+
props: { value: '', name: '', label: '', disabled: false, orientation: 'vertical' },
|
|
40
|
+
styles: [base, styles],
|
|
41
|
+
setup({ value, name, label, disabled }, host) {
|
|
42
|
+
formBind(host, { name, value, disabled });
|
|
43
|
+
|
|
44
|
+
const radios = () => [...host.querySelectorAll('ui-radio')];
|
|
45
|
+
|
|
46
|
+
// Reflect the value down: check the matching radio, give it the tab stop.
|
|
47
|
+
const sync = () => {
|
|
48
|
+
const v = value();
|
|
49
|
+
const list = radios();
|
|
50
|
+
const stop = list.find((r) => r.value === v && !r.disabled) || list.find((r) => !r.disabled);
|
|
51
|
+
for (const r of list) {
|
|
52
|
+
r.checked = r.value === v;
|
|
53
|
+
r.tabIndex = r === stop ? 0 : -1;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
effect(sync);
|
|
57
|
+
// … and once after children have upgraded/distributed.
|
|
58
|
+
queueMicrotask(() => untrack(sync));
|
|
59
|
+
|
|
60
|
+
// Group-level disabling with remember-restore, so a radio's own
|
|
61
|
+
// `disabled` survives the group toggling.
|
|
62
|
+
let claimed = null;
|
|
63
|
+
effect(() => {
|
|
64
|
+
if (disabled()) {
|
|
65
|
+
if (claimed) return;
|
|
66
|
+
claimed = new Set();
|
|
67
|
+
for (const r of radios()) if (!r.disabled) { r.disabled = true; claimed.add(r); }
|
|
68
|
+
} else if (claimed) {
|
|
69
|
+
for (const r of claimed) r.disabled = false;
|
|
70
|
+
claimed = null;
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const select = (v) => {
|
|
75
|
+
if (disabled() || v === value()) return;
|
|
76
|
+
value.set(v);
|
|
77
|
+
host.emit('change', { value: v });
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Per the APG, moving with arrows also selects the focused radio.
|
|
81
|
+
const roving = rovingTabindex(host, {
|
|
82
|
+
selector: 'ui-radio',
|
|
83
|
+
orientation: 'both',
|
|
84
|
+
skip: (el) => el.disabled,
|
|
85
|
+
onMove: (el) => select(el.value),
|
|
86
|
+
});
|
|
87
|
+
onCleanup(() => roving.destroy());
|
|
88
|
+
|
|
89
|
+
host.addEventListener('ui-radio-select', (e) => {
|
|
90
|
+
e.stopPropagation();
|
|
91
|
+
select(e.detail.value);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return html`
|
|
95
|
+
<div class="group" part="group" role="radiogroup"
|
|
96
|
+
aria-label=${() => label() || null}
|
|
97
|
+
aria-disabled=${() => (disabled() ? 'true' : null)}>
|
|
98
|
+
<slot @slotchange=${() => untrack(sync)}></slot>
|
|
99
|
+
</div>`;
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
export const tag = 'ui-radio-group';
|
|
104
|
+
export const themeVars = t;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// <ui-radio> — one Material radio button, managed by <ui-radio-group>.
|
|
2
|
+
//
|
|
3
|
+
// The interactive element is a native <button role="radio"> on a 40px touch
|
|
4
|
+
// target. The radio never checks itself: it emits `ui-radio-select` and the
|
|
5
|
+
// owning group sets `checked` back down and roves the host's tabindex (the
|
|
6
|
+
// host forwards focus to the inner button).
|
|
7
|
+
//
|
|
8
|
+
// @prop {string} value='' — REQUIRED identity within the group
|
|
9
|
+
// @prop {boolean} checked=false — set by the owning group
|
|
10
|
+
// @prop {boolean} disabled=false
|
|
11
|
+
// @prop {string} label='' — visible label; also the accessible name.
|
|
12
|
+
// Empty leaves the control unnamed (authoring error).
|
|
13
|
+
// @event ui-radio-select — pressed; detail: { value } (consumed by ui-radio-group)
|
|
14
|
+
// @part control — the <button role="radio"> (the 40px target)
|
|
15
|
+
// @part circle — the visible 20px ring
|
|
16
|
+
// @vars see `t` below (`themeVars.names`)
|
|
17
|
+
|
|
18
|
+
import { define, html, css, vars } from '@alacris/core';
|
|
19
|
+
import { sys } from '../tokens/sys.js';
|
|
20
|
+
import { base, focusRingOn } from './base.js';
|
|
21
|
+
import { ripple } from '../motion/ripple.js';
|
|
22
|
+
import { presence } from '../motion/presence.js';
|
|
23
|
+
import { fx } from '../motion/animate.js';
|
|
24
|
+
|
|
25
|
+
const t = vars('ui-radio', {
|
|
26
|
+
target: '40px',
|
|
27
|
+
circleSize: '20px',
|
|
28
|
+
outlineColor: sys.color.onSurfaceVariant,
|
|
29
|
+
checkedColor: sys.color.primary,
|
|
30
|
+
labelFg: sys.color.onSurface,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const styles = css`
|
|
34
|
+
:host { display: inline-flex; outline: none; }
|
|
35
|
+
label {
|
|
36
|
+
display: inline-flex;
|
|
37
|
+
align-items: center;
|
|
38
|
+
cursor: pointer;
|
|
39
|
+
font: ${sys.type.bodyMd};
|
|
40
|
+
letter-spacing: ${sys.tracking.bodyMd};
|
|
41
|
+
color: ${t.labelFg};
|
|
42
|
+
user-select: none;
|
|
43
|
+
}
|
|
44
|
+
label.disabled {
|
|
45
|
+
cursor: default;
|
|
46
|
+
pointer-events: none;
|
|
47
|
+
color: color-mix(in srgb, ${sys.color.onSurface} calc(${sys.state.disabledContent} * 100%), transparent);
|
|
48
|
+
}
|
|
49
|
+
.control {
|
|
50
|
+
position: relative;
|
|
51
|
+
isolation: isolate;
|
|
52
|
+
flex: none;
|
|
53
|
+
display: grid;
|
|
54
|
+
place-items: center;
|
|
55
|
+
inline-size: max(${t.circleSize}, calc(${t.target} + var(--ui-density, 0) * 4px));
|
|
56
|
+
block-size: max(${t.circleSize}, calc(${t.target} + var(--ui-density, 0) * 4px));
|
|
57
|
+
padding: 0;
|
|
58
|
+
border: none;
|
|
59
|
+
border-radius: ${sys.radius.full};
|
|
60
|
+
background: transparent;
|
|
61
|
+
color: ${t.outlineColor};
|
|
62
|
+
cursor: pointer;
|
|
63
|
+
}
|
|
64
|
+
${focusRingOn('.control')}
|
|
65
|
+
.layer {
|
|
66
|
+
position: absolute; inset: 0; z-index: -1;
|
|
67
|
+
border-radius: inherit;
|
|
68
|
+
background: currentColor;
|
|
69
|
+
opacity: 0;
|
|
70
|
+
transition: opacity ${sys.duration.short2} ${sys.easing.standard};
|
|
71
|
+
}
|
|
72
|
+
.control:hover .layer { opacity: ${sys.state.hover}; }
|
|
73
|
+
.control:focus-visible .layer { opacity: ${sys.state.focus}; }
|
|
74
|
+
.control:active .layer { opacity: ${sys.state.pressed}; }
|
|
75
|
+
|
|
76
|
+
.circle {
|
|
77
|
+
inline-size: ${t.circleSize};
|
|
78
|
+
block-size: ${t.circleSize};
|
|
79
|
+
border: 2px solid ${t.outlineColor};
|
|
80
|
+
border-radius: ${sys.radius.full};
|
|
81
|
+
display: grid;
|
|
82
|
+
place-items: center;
|
|
83
|
+
transition: border-color ${sys.duration.short2} ${sys.easing.standard};
|
|
84
|
+
}
|
|
85
|
+
.control[aria-checked="true"] { color: ${t.checkedColor}; }
|
|
86
|
+
.control[aria-checked="true"] .circle { border-color: ${t.checkedColor}; }
|
|
87
|
+
.dot {
|
|
88
|
+
inline-size: 10px;
|
|
89
|
+
block-size: 10px;
|
|
90
|
+
border-radius: ${sys.radius.full};
|
|
91
|
+
background: ${t.checkedColor};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.control:disabled { cursor: default; pointer-events: none; }
|
|
95
|
+
.control:disabled .circle {
|
|
96
|
+
border-color: color-mix(in srgb, ${sys.color.onSurface} calc(${sys.state.disabledContent} * 100%), transparent);
|
|
97
|
+
}
|
|
98
|
+
.control:disabled .dot {
|
|
99
|
+
background: color-mix(in srgb, ${sys.color.onSurface} calc(${sys.state.disabledContent} * 100%), transparent);
|
|
100
|
+
}
|
|
101
|
+
`;
|
|
102
|
+
|
|
103
|
+
define('ui-radio', {
|
|
104
|
+
props: { value: '', checked: false, disabled: false, label: '' },
|
|
105
|
+
styles: [base, styles],
|
|
106
|
+
setup({ value, checked, disabled, label }, host) {
|
|
107
|
+
let btn = null;
|
|
108
|
+
|
|
109
|
+
// The group roves tabindex on the HOST; forward its focus to the button
|
|
110
|
+
// so keyboard interaction lands on the real control.
|
|
111
|
+
host.focus = (opts) => btn?.focus(opts);
|
|
112
|
+
host.addEventListener('focus', () => btn?.focus());
|
|
113
|
+
|
|
114
|
+
const onClick = () => {
|
|
115
|
+
if (disabled()) return;
|
|
116
|
+
host.emit('ui-radio-select', { value: value() });
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
return html`
|
|
120
|
+
<label class=${() => (disabled() ? 'disabled' : null)}>
|
|
121
|
+
<button part="control" class="control" type="button" role="radio"
|
|
122
|
+
tabindex="-1"
|
|
123
|
+
aria-checked=${() => String(checked())} ?disabled=${disabled}
|
|
124
|
+
aria-labelledby=${() => (label() ? 'label' : null)}
|
|
125
|
+
@click=${onClick}
|
|
126
|
+
ref=${(el) => { btn = el; ripple(el, { disabled, centered: true }); }}>
|
|
127
|
+
<span class="layer" aria-hidden="true"></span>
|
|
128
|
+
<span part="circle" class="circle" aria-hidden="true">
|
|
129
|
+
${presence(checked, () => html`<span class="dot"></span>`, {
|
|
130
|
+
enter: fx.scaleIn,
|
|
131
|
+
exit: fx.scaleOut,
|
|
132
|
+
enterDuration: 'short4',
|
|
133
|
+
exitDuration: 'short2',
|
|
134
|
+
})}
|
|
135
|
+
</span>
|
|
136
|
+
</button>
|
|
137
|
+
${() => (label() ? html`<span id="label">${label}</span>` : null)}
|
|
138
|
+
</label>`;
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
export const tag = 'ui-radio';
|
|
143
|
+
export const themeVars = t;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// <ui-rating> — a star rating.
|
|
2
|
+
//
|
|
3
|
+
// A11y choice: the whole rating is ONE control — role="slider" with
|
|
4
|
+
// aria-valuenow/-min/-max and aria-valuetext, a single tab stop, and arrow
|
|
5
|
+
// keys adjusting the value (Home=0, End=max). The stars themselves are
|
|
6
|
+
// pointer affordances only (hover previews, click commits), so there is no
|
|
7
|
+
// tab-stop-per-star noise for keyboard and screen-reader users.
|
|
8
|
+
//
|
|
9
|
+
// Clicking the star matching the current value clears the rating to 0
|
|
10
|
+
// (MUI parity).
|
|
11
|
+
//
|
|
12
|
+
// @prop {number} value=0
|
|
13
|
+
// @prop {number} max=5
|
|
14
|
+
// @prop {boolean} readonly=false — shows the value, no interaction
|
|
15
|
+
// @prop {boolean} disabled=false
|
|
16
|
+
// @prop {string} label='Rating' — accessible name
|
|
17
|
+
// @prop {string} size='' — CSS length for the stars (overrides --ui-icon-size)
|
|
18
|
+
// @event change — committed; detail: { value }
|
|
19
|
+
// @part root — the slider container
|
|
20
|
+
// @vars see `t` below (`themeVars.names`); the fill color defaults to primary
|
|
21
|
+
|
|
22
|
+
import { define, html, css, vars, computed, signal } from '@alacris/core';
|
|
23
|
+
import { sys } from '../tokens/sys.js';
|
|
24
|
+
import { base, focusRingOn } from './base.js';
|
|
25
|
+
import './ui-icon.js';
|
|
26
|
+
|
|
27
|
+
const t = vars('ui-rating', {
|
|
28
|
+
activeFg: sys.color.primary,
|
|
29
|
+
emptyFg: sys.color.onSurfaceVariant,
|
|
30
|
+
gap: '2px',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const styles = css`
|
|
34
|
+
:host { display: inline-flex; vertical-align: middle; }
|
|
35
|
+
.root {
|
|
36
|
+
display: inline-flex;
|
|
37
|
+
align-items: center;
|
|
38
|
+
gap: ${t.gap};
|
|
39
|
+
border-radius: ${sys.radius.sm};
|
|
40
|
+
color: ${t.emptyFg};
|
|
41
|
+
cursor: pointer;
|
|
42
|
+
}
|
|
43
|
+
${focusRingOn('.root')}
|
|
44
|
+
.root.readonly, .root.disabled { cursor: default; }
|
|
45
|
+
.root.disabled { pointer-events: none; }
|
|
46
|
+
.star {
|
|
47
|
+
display: inline-flex;
|
|
48
|
+
transition: color ${sys.duration.short2} ${sys.easing.standard},
|
|
49
|
+
scale ${sys.duration.short2} ${sys.easing.standard};
|
|
50
|
+
}
|
|
51
|
+
.star.filled { color: ${t.activeFg}; }
|
|
52
|
+
.root:not(.readonly):not(.disabled) .star:hover { scale: 1.15; }
|
|
53
|
+
.root.disabled .star,
|
|
54
|
+
.root.disabled .star.filled {
|
|
55
|
+
color: color-mix(in srgb, ${sys.color.onSurface} calc(${sys.state.disabledContent} * 100%), transparent);
|
|
56
|
+
}
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
define('ui-rating', {
|
|
60
|
+
props: { value: 0, max: 5, readonly: false, disabled: false, label: 'Rating', size: '' },
|
|
61
|
+
styles: [base, styles],
|
|
62
|
+
setup({ value, max, readonly, disabled, label, size }, host) {
|
|
63
|
+
const hover = signal(0);
|
|
64
|
+
const shown = computed(() => (hover() > 0 ? hover() : value()));
|
|
65
|
+
const interactive = () => !readonly() && !disabled();
|
|
66
|
+
|
|
67
|
+
const set = (next) => {
|
|
68
|
+
if (next === value()) return;
|
|
69
|
+
value.set(next);
|
|
70
|
+
host.emit('change', { value: next });
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const commit = (n) => {
|
|
74
|
+
if (!interactive()) return;
|
|
75
|
+
hover.set(0);
|
|
76
|
+
set(n === value() ? 0 : n);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const onKeydown = (e) => {
|
|
80
|
+
if (!interactive()) return;
|
|
81
|
+
let next;
|
|
82
|
+
switch (e.key) {
|
|
83
|
+
case 'ArrowRight': case 'ArrowUp': next = Math.min(max(), value() + 1); break;
|
|
84
|
+
case 'ArrowLeft': case 'ArrowDown': next = Math.max(0, value() - 1); break;
|
|
85
|
+
case 'Home': next = 0; break;
|
|
86
|
+
case 'End': next = max(); break;
|
|
87
|
+
default: return;
|
|
88
|
+
}
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
set(next);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const cls = computed(() =>
|
|
94
|
+
['root', readonly() && 'readonly', disabled() && 'disabled'].filter(Boolean).join(' '));
|
|
95
|
+
|
|
96
|
+
return html`
|
|
97
|
+
<div part="root" class=${cls} role="slider"
|
|
98
|
+
tabindex=${() => (disabled() ? null : '0')}
|
|
99
|
+
aria-label=${label}
|
|
100
|
+
aria-valuemin="0" aria-valuemax=${max} aria-valuenow=${value}
|
|
101
|
+
aria-valuetext=${() => `${value()} of ${max()} stars`}
|
|
102
|
+
aria-readonly=${() => (readonly() ? 'true' : null)}
|
|
103
|
+
aria-disabled=${() => (disabled() ? 'true' : null)}
|
|
104
|
+
style=${() => (size() ? { '--ui-icon-size': size() } : null)}
|
|
105
|
+
@keydown=${onKeydown}
|
|
106
|
+
@pointerleave=${() => hover.set(0)}>
|
|
107
|
+
${() => {
|
|
108
|
+
const s = shown();
|
|
109
|
+
const m = Math.max(0, max());
|
|
110
|
+
const stars = [];
|
|
111
|
+
for (let n = 1; n <= m; n++) {
|
|
112
|
+
stars.push(html`
|
|
113
|
+
<span class="star ${s >= n ? 'filled' : ''}" aria-hidden="true"
|
|
114
|
+
@click=${() => commit(n)}
|
|
115
|
+
@pointerenter=${() => interactive() && hover.set(n)}>
|
|
116
|
+
<ui-icon name=${s >= n ? 'star' : 'star-border'}></ui-icon>
|
|
117
|
+
</span>`);
|
|
118
|
+
}
|
|
119
|
+
return stars;
|
|
120
|
+
}}
|
|
121
|
+
</div>`;
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
export const tag = 'ui-rating';
|
|
126
|
+
export const themeVars = t;
|