@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,95 @@
1
+ // <ui-badge> — a small status badge anchored to the top-end corner of its
2
+ // slotted content (an icon button, a nav item, an avatar…).
3
+ //
4
+ // Hidden while `value` is 0 unless `dot`. Counts above `max` render '99+'
5
+ // style. Appearing/disappearing animates with a scale-in/out.
6
+ //
7
+ // @prop {number} value=0 — the count; 0 hides the badge (unless `dot`)
8
+ // @prop {number} max=99 — counts above this render as 'max+'
9
+ // @prop {boolean} dot=false — a plain 8px dot instead of a count
10
+ // @prop {boolean} show=true — master visibility switch
11
+ // @prop {string} label='' — accessible meaning of the badge ('3 unread');
12
+ // empty marks the badge decorative
13
+ // @slot (default) — the anchored content
14
+ // @part badge — the badge pill/dot
15
+ // @vars --ui-badge-bg, --ui-badge-fg, --ui-badge-size, --ui-badge-dot-size,
16
+ // --ui-badge-font
17
+
18
+ import { define, html, css, vars, computed } from '@alacris/core';
19
+ import { sys } from '../tokens/sys.js';
20
+ import { base } from './base.js';
21
+ import { presence } from '../motion/presence.js';
22
+ import { fx } from '../motion/animate.js';
23
+
24
+ const t = vars('ui-badge', {
25
+ bg: sys.color.error,
26
+ fg: sys.color.onError,
27
+ size: '16px',
28
+ dotSize: '8px',
29
+ font: sys.type.labelSm,
30
+ tracking: sys.tracking.labelSm,
31
+ });
32
+
33
+ const styles = css`
34
+ :host {
35
+ position: relative;
36
+ display: inline-flex;
37
+ vertical-align: middle;
38
+ }
39
+ .badge {
40
+ position: absolute;
41
+ inset-block-start: 0;
42
+ inset-inline-end: 0;
43
+ transform: translate(50%, -50%);
44
+ z-index: 1;
45
+ display: inline-flex;
46
+ align-items: center;
47
+ justify-content: center;
48
+ min-block-size: ${t.size};
49
+ min-inline-size: ${t.size};
50
+ padding-inline: ${sys.space(1)};
51
+ border-radius: ${sys.radius.full};
52
+ background: ${t.bg};
53
+ color: ${t.fg};
54
+ font: ${t.font};
55
+ letter-spacing: ${t.tracking};
56
+ line-height: 1;
57
+ pointer-events: none;
58
+ white-space: nowrap;
59
+ }
60
+ .badge.dot {
61
+ min-block-size: ${t.dotSize};
62
+ min-inline-size: ${t.dotSize};
63
+ block-size: ${t.dotSize};
64
+ inline-size: ${t.dotSize};
65
+ padding: 0;
66
+ }
67
+ `;
68
+
69
+ define('ui-badge', {
70
+ props: { value: 0, max: 99, dot: false, show: true, label: '' },
71
+ styles: [base, styles],
72
+ setup({ value, max, dot, show, label }) {
73
+ const visible = computed(() => show() && (dot() || value() > 0));
74
+ const text = computed(() =>
75
+ dot() ? '' : value() > max() ? `${max()}+` : String(value()));
76
+
77
+ const view = () => html`
78
+ <span part="badge"
79
+ class=${() => `badge${dot() ? ' dot' : ''}`}
80
+ aria-label=${() => label() || null}
81
+ aria-hidden=${() => (label() ? null : 'true')}>${text}</span>`;
82
+
83
+ return html`
84
+ <slot></slot>
85
+ ${presence(visible, view, {
86
+ enter: fx.scaleIn,
87
+ exit: fx.scaleOut,
88
+ enterDuration: 'short4',
89
+ exitDuration: 'short2',
90
+ })}`;
91
+ },
92
+ });
93
+
94
+ export const tag = 'ui-badge';
95
+ export const themeVars = t;
@@ -0,0 +1,78 @@
1
+ // <ui-bottom-app-bar> — the Material bottom app bar.
2
+ //
3
+ // <ui-bottom-app-bar>
4
+ // <ui-icon-button slot="navigation" icon="menu" label="Menu"></ui-icon-button>
5
+ // <ui-icon-button slot="actions" icon="search" label="Search"></ui-icon-button>
6
+ // <ui-fab slot="fab" icon="add"></ui-fab>
7
+ // </ui-bottom-app-bar>
8
+ //
9
+ // A surface for a leading navigation icon, trailing actions, and an optional
10
+ // FAB. The host flows with the page — pin it with CSS when it should hug the
11
+ // viewport bottom. Distinct from <ui-bottom-nav>, which is destination tabs.
12
+ //
13
+ // @prop {string} label='Bottom app bar' — accessible name of the toolbar
14
+ // @prop {string} fabAlign='end' — end | center | start — where the FAB sits
15
+ // @slot navigation — leading icon button
16
+ // @slot actions — trailing icon buttons
17
+ // @slot fab — optional <ui-fab>
18
+ // @part bar, navigation, actions, fab
19
+ // @vars see `t` below (`themeVars.names`)
20
+
21
+ import { define, html, css, vars, computed } from '@alacris/core';
22
+ import { sys } from '../tokens/sys.js';
23
+ import { base } from './base.js';
24
+
25
+ const t = vars('ui-bottom-app-bar', {
26
+ height: '80px',
27
+ bg: sys.color.surfaceContainer,
28
+ fg: sys.color.onSurface,
29
+ shadow: sys.elevation[2],
30
+ });
31
+
32
+ const styles = css`
33
+ :host { display: block; }
34
+ .bar {
35
+ position: relative;
36
+ display: flex;
37
+ align-items: center;
38
+ block-size: ${t.height};
39
+ padding-inline: ${sys.space(1)} ${sys.space(4)};
40
+ background: ${t.bg};
41
+ color: ${t.fg};
42
+ box-shadow: ${t.shadow};
43
+ }
44
+ .nav, .actions, .fab {
45
+ display: inline-flex;
46
+ align-items: center;
47
+ gap: ${sys.space(1)};
48
+ }
49
+ .nav { order: 0; }
50
+ .actions { order: 1; margin-inline-start: auto; }
51
+ .fab { order: 2; margin-inline-start: ${sys.space(2)}; }
52
+ .start .fab { order: 1; }
53
+ .start .actions { order: 2; }
54
+ .center .fab {
55
+ order: 0;
56
+ position: absolute;
57
+ inset-inline-start: 50%;
58
+ translate: -50% 0;
59
+ margin: 0;
60
+ }
61
+ `;
62
+
63
+ define('ui-bottom-app-bar', {
64
+ props: { label: 'Bottom app bar', fabAlign: 'end' },
65
+ styles: [base, styles],
66
+ setup({ label, fabAlign }) {
67
+ const cls = computed(() => `bar ${fabAlign() === 'center' ? 'center' : fabAlign() === 'start' ? 'start' : 'end'}`);
68
+ return html`
69
+ <div class=${cls} part="bar" role="toolbar" aria-label=${() => label() || 'Bottom app bar'}>
70
+ <div class="nav" part="navigation"><slot name="navigation"></slot></div>
71
+ <div class="fab" part="fab"><slot name="fab"></slot></div>
72
+ <div class="actions" part="actions"><slot name="actions"></slot></div>
73
+ </div>`;
74
+ },
75
+ });
76
+
77
+ export const tag = 'ui-bottom-app-bar';
78
+ export const themeVars = t;
@@ -0,0 +1,83 @@
1
+ // <ui-bottom-nav> — the Material navigation bar.
2
+ //
3
+ // <ui-bottom-nav value=${route} @change=${(e) => route(e.detail.value)}>
4
+ // <ui-nav-item value="home" icon="home" label="Home"></ui-nav-item>
5
+ // <ui-nav-item value="search" icon="search" label="Search"></ui-nav-item>
6
+ // </ui-bottom-nav>
7
+ //
8
+ // @prop {string} value='' — the selected item's `value`
9
+ // @prop {string} label='' — accessible name of the <nav>
10
+ // @event change — a destination was chosen; detail: { value }
11
+ // @slot (default) — <ui-nav-item> children
12
+ // @part bar — the <nav> container
13
+ // @vars see `t` below (`themeVars.names`)
14
+ //
15
+ // The host is `display: block` and flows with the page — apps that want the
16
+ // bar pinned to the viewport position it themselves (position: fixed; bottom:
17
+ // 0; left: 0; right: 0). Selection reflects down: every `value` write (or
18
+ // slotchange) sets `selected` on the children. Keyboard: one Tab stop, arrow
19
+ // keys rove between items (rovingTabindex, horizontal).
20
+
21
+ import { define, html, css, vars, effect, onCleanup } from '@alacris/core';
22
+ import { sys } from '../tokens/sys.js';
23
+ import { base } from './base.js';
24
+ import { rovingTabindex } from '../util/keys.js';
25
+ import './ui-nav-item.js';
26
+
27
+ const t = vars('ui-bottom-nav', {
28
+ height: '80px',
29
+ bg: sys.color.surfaceContainer,
30
+ shadow: 'none',
31
+ });
32
+
33
+ const styles = css`
34
+ :host { display: block; }
35
+ .bar {
36
+ display: flex;
37
+ align-items: stretch;
38
+ justify-content: space-around;
39
+ block-size: ${t.height};
40
+ background: ${t.bg};
41
+ box-shadow: ${t.shadow};
42
+ }
43
+ ::slotted(ui-nav-item) { flex: 1; max-inline-size: 168px; }
44
+ `;
45
+
46
+ define('ui-bottom-nav', {
47
+ props: { value: '', label: '' },
48
+ styles: [base, styles],
49
+ setup({ value, label }, host) {
50
+ const roving = rovingTabindex(host, {
51
+ selector: 'ui-nav-item',
52
+ orientation: 'horizontal',
53
+ skip: (el) => el.disabled,
54
+ });
55
+ onCleanup(() => roving.destroy());
56
+
57
+ const sync = () => {
58
+ for (const item of host.querySelectorAll('ui-nav-item')) {
59
+ item.selected = item.value === value();
60
+ }
61
+ roving.refresh();
62
+ };
63
+ effect(sync); // re-runs on every `value` write; slotchange calls it too
64
+
65
+ const onSelect = (e) => {
66
+ const v = e.detail?.value;
67
+ if (v === undefined || v === value()) return;
68
+ value.set(v);
69
+ host.emit('change', { value: v });
70
+ };
71
+ // Items are light-DOM children; their bubbling select events reach the host.
72
+ host.addEventListener('ui-nav-select', onSelect);
73
+ onCleanup(() => host.removeEventListener('ui-nav-select', onSelect));
74
+
75
+ return html`
76
+ <nav class="bar" part="bar" aria-label=${() => label() || null}>
77
+ <slot @slotchange=${sync}></slot>
78
+ </nav>`;
79
+ },
80
+ });
81
+
82
+ export const tag = 'ui-bottom-nav';
83
+ export const themeVars = t;
@@ -0,0 +1,100 @@
1
+ // <ui-breadcrumbs> — a navigation trail of slotted links.
2
+ //
3
+ // <ui-breadcrumbs separator-icon="chevron-right">
4
+ // <a href="/">Home</a>
5
+ // <a href="/library">Library</a>
6
+ // <span aria-current="page">Data</span>
7
+ // </ui-breadcrumbs>
8
+ //
9
+ // @prop {string} separator='/' — separator text between items
10
+ // @prop {string} separatorIcon='' — registry icon name; overrides `separator`
11
+ // @prop {string} label='Breadcrumb' — accessible name of the <nav>
12
+ // @slot (default) — the trail items. Children MUST be elements (<a>, <span>,
13
+ // <ui-button variant="text">, …), never bare text nodes —
14
+ // separators are drawn with ::slotted(*)::before, which only
15
+ // exists on element children. Mark the current page with
16
+ // aria-current="page".
17
+ // @part nav, list
18
+ // @vars see `t` below (`themeVars.names`)
19
+ //
20
+ // Separators are rendered as generated content on every item but the first:
21
+ // text mode sets the `content` from a private custom property; icon mode masks
22
+ // a currentColor box with the icon's path, so it themes like any glyph.
23
+
24
+ import { define, html, css, vars, effect } from '@alacris/core';
25
+ import { sys } from '../tokens/sys.js';
26
+ import { base } from './base.js';
27
+ import { iconPath, iconsVersion } from '../util/icons.js';
28
+
29
+ const t = vars('ui-breadcrumbs', {
30
+ fg: sys.color.onSurfaceVariant,
31
+ currentFg: sys.color.onSurface,
32
+ separatorFg: sys.color.onSurfaceVariant,
33
+ gap: sys.space(2),
34
+ font: sys.type.bodyMd,
35
+ tracking: sys.tracking.bodyMd,
36
+ });
37
+
38
+ const styles = css`
39
+ :host { display: block; }
40
+ nav {
41
+ font: ${t.font};
42
+ letter-spacing: ${t.tracking};
43
+ color: ${t.fg};
44
+ }
45
+ .list {
46
+ display: flex;
47
+ align-items: center;
48
+ flex-wrap: wrap;
49
+ gap: ${t.gap};
50
+ }
51
+ ::slotted(*) { display: inline-flex; align-items: center; color: inherit; }
52
+ ::slotted(a) { color: inherit; text-decoration: none; }
53
+ ::slotted(a:hover) { color: ${t.currentFg}; text-decoration: underline; }
54
+ ::slotted([aria-current="page"]) { color: ${t.currentFg}; }
55
+ ::slotted(*:not(:first-child))::before {
56
+ content: var(--_ui-breadcrumbs-sep, "/");
57
+ display: inline-block;
58
+ flex: none;
59
+ margin-inline-end: ${t.gap};
60
+ color: ${t.separatorFg};
61
+ inline-size: var(--_ui-breadcrumbs-sep-size, auto);
62
+ block-size: var(--_ui-breadcrumbs-sep-size, auto);
63
+ background: var(--_ui-breadcrumbs-sep-bg, none);
64
+ -webkit-mask: var(--_ui-breadcrumbs-sep-mask, none) center / contain no-repeat;
65
+ mask: var(--_ui-breadcrumbs-sep-mask, none) center / contain no-repeat;
66
+ }
67
+ `;
68
+
69
+ define('ui-breadcrumbs', {
70
+ props: { separator: '/', separatorIcon: '', label: 'Breadcrumb' },
71
+ styles: [base, styles],
72
+ setup({ separator, separatorIcon, label }, host) {
73
+ effect(() => {
74
+ iconsVersion(); // pick up icons registered after first render
75
+ const d = separatorIcon() && iconPath(separatorIcon());
76
+ const s = host.style;
77
+ if (d) {
78
+ const svgMarkup =
79
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="${d}"/></svg>`;
80
+ s.setProperty('--_ui-breadcrumbs-sep', '""');
81
+ s.setProperty('--_ui-breadcrumbs-sep-mask', `url("data:image/svg+xml,${encodeURIComponent(svgMarkup)}")`);
82
+ s.setProperty('--_ui-breadcrumbs-sep-bg', 'currentColor');
83
+ s.setProperty('--_ui-breadcrumbs-sep-size', '1em');
84
+ } else {
85
+ s.setProperty('--_ui-breadcrumbs-sep', JSON.stringify(separator()));
86
+ s.removeProperty('--_ui-breadcrumbs-sep-mask');
87
+ s.removeProperty('--_ui-breadcrumbs-sep-bg');
88
+ s.removeProperty('--_ui-breadcrumbs-sep-size');
89
+ }
90
+ });
91
+
92
+ return html`
93
+ <nav part="nav" aria-label=${label}>
94
+ <div class="list" part="list"><slot></slot></div>
95
+ </nav>`;
96
+ },
97
+ });
98
+
99
+ export const tag = 'ui-breadcrumbs';
100
+ export const themeVars = t;
@@ -0,0 +1,49 @@
1
+ // <ui-button-group> — joins slotted <ui-button>s into one segmented bar.
2
+ //
3
+ // Slotted buttons lose their own rounding (their `--ui-button-radius` is
4
+ // zeroed); the group container carries the full radius and clips, and a 1px
5
+ // gap lets the container's divider color show between segments — the Material
6
+ // connected button group.
7
+ //
8
+ // @prop {string} label='' — accessible name for the group
9
+ // @slot (default) — the <ui-button> children
10
+ // @part group — the clipping container
11
+ // @vars see `t` below (`themeVars.names`)
12
+
13
+ import { define, html, css, vars } from '@alacris/core';
14
+ import { sys } from '../tokens/sys.js';
15
+ import { base } from './base.js';
16
+ import './ui-button.js';
17
+
18
+ const t = vars('ui-button-group', {
19
+ radius: sys.radius.full,
20
+ divider: sys.color.outlineVariant,
21
+ });
22
+
23
+ const styles = css`
24
+ :host { display: inline-flex; vertical-align: middle; }
25
+ .group {
26
+ display: inline-flex;
27
+ align-items: stretch;
28
+ gap: 1px;
29
+ background: ${t.divider};
30
+ border-radius: ${t.radius};
31
+ overflow: hidden;
32
+ }
33
+ ::slotted(ui-button) { --ui-button-radius: 0; }
34
+ `;
35
+
36
+ define('ui-button-group', {
37
+ props: { label: '' },
38
+ styles: [base, styles],
39
+ setup({ label }) {
40
+ return html`
41
+ <div class="group" part="group" role="group"
42
+ aria-label=${() => label() || null}>
43
+ <slot></slot>
44
+ </div>`;
45
+ },
46
+ });
47
+
48
+ export const tag = 'ui-button-group';
49
+ export const themeVars = t;
@@ -0,0 +1,143 @@
1
+ // <ui-button> — the Material common button, all five variants.
2
+ //
3
+ // @prop {string} variant='filled' — filled | tonal | outlined | text | elevated
4
+ // @prop {boolean} disabled=false
5
+ // @prop {string} type='button' — button | submit (submit reaches the enclosing form)
6
+ // @prop {string} href='' — renders a link styled as a button
7
+ // @prop {string} target='' — link target when href is set
8
+ // @event (native click bubbles; no custom event)
9
+ // @slot (default) — label
10
+ // @slot icon — leading icon
11
+ // @slot trailing — trailing icon
12
+ // @part control — the <button>/<a>
13
+ // @vars see `t` below (`themeVars.names`)
14
+
15
+ import { define, html, css, vars, computed, signal } from '@alacris/core';
16
+ import { sys } from '../tokens/sys.js';
17
+ import { base, focusRingOn, stateLayerOn } from './base.js';
18
+ import { ripple } from '../motion/ripple.js';
19
+
20
+ const t = vars('ui-button', {
21
+ height: '40px',
22
+ padInline: sys.space(6),
23
+ gap: sys.space(2),
24
+ radius: sys.radius.full,
25
+ font: sys.type.labelLg,
26
+ tracking: sys.tracking.labelLg,
27
+ filledBg: sys.color.primary,
28
+ filledFg: sys.color.onPrimary,
29
+ tonalBg: sys.color.secondaryContainer,
30
+ tonalFg: sys.color.onSecondaryContainer,
31
+ outlinedFg: sys.color.primary,
32
+ outlineColor: sys.color.outline,
33
+ textFg: sys.color.primary,
34
+ elevatedBg: sys.color.surfaceContainerLow,
35
+ elevatedFg: sys.color.primary,
36
+ });
37
+
38
+ const styles = css`
39
+ :host { display: inline-flex; vertical-align: middle; }
40
+ .control {
41
+ position: relative;
42
+ isolation: isolate;
43
+ display: inline-flex;
44
+ align-items: center;
45
+ justify-content: center;
46
+ gap: ${t.gap};
47
+ block-size: calc(${t.height} + var(--ui-density, 0) * 4px);
48
+ padding-inline: ${t.padInline};
49
+ border: none;
50
+ border-radius: ${t.radius};
51
+ font: ${t.font};
52
+ letter-spacing: ${t.tracking};
53
+ background: transparent;
54
+ color: inherit;
55
+ cursor: pointer;
56
+ user-select: none;
57
+ text-decoration: none;
58
+ white-space: nowrap;
59
+ transition: box-shadow ${sys.duration.short4} ${sys.easing.standard};
60
+ --ui-icon-size: 1.125rem;
61
+ }
62
+ ${focusRingOn('.control')}
63
+ .layer {
64
+ position: absolute; inset: 0; z-index: -1;
65
+ border-radius: inherit;
66
+ background: currentColor;
67
+ opacity: 0;
68
+ transition: opacity ${sys.duration.short2} ${sys.easing.standard};
69
+ }
70
+ ${stateLayerOn('.control')}
71
+
72
+ .filled { background: ${t.filledBg}; color: ${t.filledFg}; }
73
+ .filled:hover { box-shadow: ${sys.elevation[1]}; }
74
+ .filled:active { box-shadow: none; }
75
+ .tonal { background: ${t.tonalBg}; color: ${t.tonalFg}; }
76
+ .outlined {
77
+ color: ${t.outlinedFg};
78
+ border: 1px solid ${t.outlineColor};
79
+ }
80
+ .text { color: ${t.textFg}; padding-inline: ${sys.space(3)}; }
81
+ .elevated { background: ${t.elevatedBg}; color: ${t.elevatedFg}; box-shadow: ${sys.elevation[1]}; }
82
+ .elevated:hover { box-shadow: ${sys.elevation[2]}; }
83
+ .elevated:active { box-shadow: ${sys.elevation[1]}; }
84
+
85
+ /* Leading icon: 16dp start padding. Trailing icon: 16dp end padding. */
86
+ .with-icon:not(.text) { padding-inline-start: ${sys.space(4)}; }
87
+ .with-trailing:not(.text) { padding-inline-end: ${sys.space(4)}; }
88
+ .text.with-icon { padding-inline-end: ${sys.space(4)}; }
89
+ .text.with-trailing { padding-inline-start: ${sys.space(4)}; }
90
+
91
+ .control:disabled, .control[aria-disabled="true"] {
92
+ cursor: default;
93
+ pointer-events: none;
94
+ box-shadow: none;
95
+ color: color-mix(in srgb, ${sys.color.onSurface} calc(${sys.state.disabledContent} * 100%), transparent);
96
+ }
97
+ .filled:disabled, .tonal:disabled, .elevated:disabled,
98
+ .filled[aria-disabled="true"], .tonal[aria-disabled="true"], .elevated[aria-disabled="true"] {
99
+ background: color-mix(in srgb, ${sys.color.onSurface} calc(${sys.state.disabledContainer} * 100%), transparent);
100
+ }
101
+ .outlined:disabled, .outlined[aria-disabled="true"] {
102
+ border-color: color-mix(in srgb, ${sys.color.onSurface} calc(${sys.state.disabledContainer} * 100%), transparent);
103
+ }
104
+ `;
105
+
106
+ define('ui-button', {
107
+ props: { variant: 'filled', disabled: false, type: 'button', href: '', target: '' },
108
+ styles: [base, styles],
109
+ setup({ variant, disabled, type, href, target }, host) {
110
+ const hasIcon = signal(false);
111
+ const hasTrailing = signal(false);
112
+ const onSlot = (sig) => (el) => {
113
+ const sync = () => sig.set(el.assignedElements().length > 0);
114
+ el.addEventListener('slotchange', sync);
115
+ sync();
116
+ };
117
+ const cls = computed(() =>
118
+ `control ${variant()}${hasIcon() ? ' with-icon' : ''}${hasTrailing() ? ' with-trailing' : ''}`);
119
+
120
+ const onClick = (e) => {
121
+ if (disabled()) { e.preventDefault(); e.stopPropagation(); return; }
122
+ // A shadow-DOM button cannot submit the host's form; bridge it.
123
+ if (!href() && type() === 'submit') host.closest('form')?.requestSubmit();
124
+ };
125
+
126
+ const inner = html`
127
+ <span class="layer" aria-hidden="true"></span>
128
+ <slot name="icon" ref=${onSlot(hasIcon)}></slot>
129
+ <slot></slot>
130
+ <slot name="trailing" ref=${onSlot(hasTrailing)}></slot>`;
131
+
132
+ return html`${() =>
133
+ href()
134
+ ? html`<a part="control" class=${cls} href=${href} target=${() => target() || null}
135
+ aria-disabled=${() => (disabled() ? 'true' : null)} tabindex=${() => (disabled() ? '-1' : null)}
136
+ @click=${onClick} ref=${(el) => ripple(el, { disabled })}>${inner}</a>`
137
+ : html`<button part="control" class=${cls} type="button" ?disabled=${disabled}
138
+ @click=${onClick} ref=${(el) => ripple(el, { disabled })}>${inner}</button>`}`;
139
+ },
140
+ });
141
+
142
+ export const tag = 'ui-button';
143
+ export const themeVars = t;
@@ -0,0 +1,86 @@
1
+ // <ui-card> — a Material card surface.
2
+ //
3
+ // @prop {string} variant='elevated' — elevated | filled | outlined
4
+ // @prop {boolean} interactive=false — hover elevation + ripple + button semantics
5
+ // @event action — interactive card activated (click/Enter/Space)
6
+ // @slot (default) — card body (compose freely; padding is yours via parts/vars)
7
+ // @slot media — full-bleed media at the top
8
+ // @part container — the card surface
9
+ // @part body — padded wrapper around the default slot
10
+ // @vars see `t` below
11
+
12
+ import { define, html, css, vars, computed } from '@alacris/core';
13
+ import { sys } from '../tokens/sys.js';
14
+ import { base, focusRingOn } from './base.js';
15
+ import { ripple } from '../motion/ripple.js';
16
+
17
+ const t = vars('ui-card', {
18
+ radius: sys.radius.md,
19
+ bg: sys.color.surfaceContainerLow,
20
+ fg: sys.color.onSurface,
21
+ filledBg: sys.color.surfaceContainerHighest,
22
+ outlineColor: sys.color.outlineVariant,
23
+ padding: sys.space(4),
24
+ });
25
+
26
+ const styles = css`
27
+ :host { display: block; }
28
+ .container {
29
+ position: relative;
30
+ isolation: isolate;
31
+ display: flex;
32
+ flex-direction: column;
33
+ border-radius: ${t.radius};
34
+ color: ${t.fg};
35
+ overflow: hidden;
36
+ transition: box-shadow ${sys.duration.short4} ${sys.easing.standard};
37
+ }
38
+ .elevated { background: ${t.bg}; box-shadow: ${sys.elevation[1]}; }
39
+ .filled { background: ${t.filledBg}; }
40
+ .outlined { background: ${sys.color.surface}; border: 1px solid ${t.outlineColor}; }
41
+ .body { padding: ${t.padding}; }
42
+ .interactive { cursor: pointer; }
43
+ ${focusRingOn('.interactive')}
44
+ .interactive .layer {
45
+ position: absolute; inset: 0; z-index: 1;
46
+ background: currentColor; opacity: 0; pointer-events: none;
47
+ transition: opacity ${sys.duration.short2} ${sys.easing.standard};
48
+ }
49
+ .interactive:hover .layer { opacity: ${sys.state.hover}; }
50
+ .interactive:focus-visible .layer { opacity: ${sys.state.focus}; }
51
+ .interactive:active .layer { opacity: ${sys.state.pressed}; }
52
+ .interactive.elevated:hover { box-shadow: ${sys.elevation[2]}; }
53
+ .interactive.elevated:active { box-shadow: ${sys.elevation[1]}; }
54
+ .interactive.outlined:hover, .interactive.filled:hover { box-shadow: ${sys.elevation[1]}; }
55
+ .interactive.outlined:active, .interactive.filled:active { box-shadow: none; }
56
+ `;
57
+
58
+ define('ui-card', {
59
+ props: { variant: 'elevated', interactive: false },
60
+ styles: [base, styles],
61
+ setup({ variant, interactive }, host) {
62
+ const cls = computed(() => `container ${variant()}${interactive() ? ' interactive' : ''}`);
63
+
64
+ const activate = () => interactive() && host.emit('action');
65
+ const onKeydown = (e) => {
66
+ if (interactive() && (e.key === 'Enter' || e.key === ' ')) {
67
+ e.preventDefault();
68
+ activate();
69
+ }
70
+ };
71
+
72
+ return html`
73
+ <div part="container" class=${cls}
74
+ role=${() => (interactive() ? 'button' : null)}
75
+ tabindex=${() => (interactive() ? '0' : null)}
76
+ @click=${activate} @keydown=${onKeydown}
77
+ ref=${(el) => ripple(el, { disabled: () => !interactive() })}>
78
+ <span class="layer" aria-hidden="true"></span>
79
+ <slot name="media"></slot>
80
+ <div class="body" part="body"><slot></slot></div>
81
+ </div>`;
82
+ },
83
+ });
84
+
85
+ export const tag = 'ui-card';
86
+ export const themeVars = t;