@moni-labs/moni-ui 0.4.3 → 0.4.4

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 (31) hide show
  1. package/README.md +6 -6
  2. package/custom-elements.json +53 -8
  3. package/dist/browser/moni-ui.iife.min.js +99 -6
  4. package/dist/browser/moni-ui.iife.min.js.br +0 -0
  5. package/dist/browser/moni-ui.iife.min.js.gz +0 -0
  6. package/dist/browser/moni-ui.iife.min.js.map +1 -1
  7. package/dist/browser/moni-ui.min.js +133 -30
  8. package/dist/browser/moni-ui.min.js.br +0 -0
  9. package/dist/browser/moni-ui.min.js.gz +0 -0
  10. package/dist/browser/moni-ui.min.js.map +1 -1
  11. package/dist/cdn/components/moni-button-group.js +72 -3
  12. package/dist/cdn/components/moni-button.js +1 -0
  13. package/dist/cdn/components/moni-loading-indicator.js +21 -2
  14. package/dist/cdn/components/moni-time-picker.js +5 -1
  15. package/dist/cdn/importmap.json +98 -98
  16. package/dist/components/moni-button-group.d.ts +14 -0
  17. package/dist/components/moni-button-group.d.ts.map +1 -1
  18. package/dist/components/moni-button-group.js +130 -25
  19. package/dist/components/moni-button.d.ts.map +1 -1
  20. package/dist/components/moni-button.js +1 -0
  21. package/dist/components/moni-loading-indicator.d.ts.map +1 -1
  22. package/dist/components/moni-loading-indicator.js +21 -2
  23. package/dist/components/moni-time-picker.d.ts.map +1 -1
  24. package/dist/components/moni-time-picker.js +5 -1
  25. package/package.json +1 -1
  26. package/src/components/moni-button-group.test.ts +76 -0
  27. package/src/components/moni-button-group.ts +130 -34
  28. package/src/components/moni-button.ts +1 -0
  29. package/src/components/moni-loading-indicator.test.ts +9 -0
  30. package/src/components/moni-loading-indicator.ts +21 -2
  31. package/src/components/moni-time-picker.ts +5 -1
@@ -59,6 +59,17 @@ import { MoniElement, sharedStyles } from './_base/index.js';
59
59
  */
60
60
  @customElement('moni-button-group')
61
61
  export class MoniButtonGroup extends MoniElement {
62
+ private readonly _activeObserver = new MutationObserver(() => this.updateSelectionNeighbors());
63
+
64
+ override connectedCallback() {
65
+ super.connectedCallback();
66
+ this._activeObserver.observe(this, { subtree: true, attributes: true, attributeFilter: ['active'] });
67
+ }
68
+
69
+ override disconnectedCallback() {
70
+ this._activeObserver.disconnect();
71
+ super.disconnectedCallback();
72
+ }
62
73
  /**
63
74
  * Variante visual del grupo de botones.
64
75
  * - `standard`: Los elementos se espacian normalmente.
@@ -84,6 +95,18 @@ export class MoniButtonGroup extends MoniElement {
84
95
  @property({ type: Boolean, reflect: true })
85
96
  multi = false;
86
97
 
98
+ /** Impide que el grupo quede sin selección. */
99
+ @property({ type: Boolean, reflect: true, attribute: 'selection-required' })
100
+ selectionRequired = false;
101
+
102
+ /** Forma base común de los botones. La selección invierte round ↔ square. */
103
+ @property({ reflect: true })
104
+ shape: 'round' | 'square' = 'round';
105
+
106
+ /** Controla si los botones conservan su ancho intrínseco o llenan la superficie. */
107
+ @property({ reflect: true })
108
+ resizing: 'fixed' | 'flexible' = 'fixed';
109
+
87
110
  /**
88
111
  * Espacio CSS personalizado entre botones (ej., '1rem').
89
112
  * Solo se aplica cuando la variante es 'standard'.
@@ -124,12 +147,18 @@ export class MoniButtonGroup extends MoniElement {
124
147
  display: inline-flex;
125
148
  align-items: center;
126
149
  vertical-align: middle;
150
+ max-inline-size: 100%;
151
+ }
152
+ :host([variant='connected']),
153
+ :host([resizing='flexible']) {
154
+ inline-size: 100%;
127
155
  }
128
156
 
129
157
  .group-container {
130
158
  display: inline-flex;
131
159
  align-items: center;
132
160
  width: 100%;
161
+ flex-wrap: nowrap;
133
162
  }
134
163
 
135
164
  slot {
@@ -159,6 +188,69 @@ export class MoniButtonGroup extends MoniElement {
159
188
  :host([variant='connected']) .group-container {
160
189
  gap: 0.125rem; /* 2dp */
161
190
  }
191
+ :host([variant='connected']) ::slotted(moni-button),
192
+ :host([variant='connected']) ::slotted(moni-icon-button),
193
+ :host([resizing='flexible']) ::slotted(moni-button) {
194
+ flex: 1 1 0;
195
+ }
196
+ :host([variant='connected']) ::slotted(moni-button) {
197
+ --moni-button-inline-size: 100%;
198
+ min-inline-size: 0;
199
+ }
200
+ :host([variant='connected'][size='xsmall']) ::slotted(moni-button),
201
+ :host([variant='connected'][size='xsmall']) ::slotted(moni-icon-button),
202
+ :host([variant='connected'][size='small']) ::slotted(moni-button),
203
+ :host([variant='connected'][size='small']) ::slotted(moni-icon-button) {
204
+ min-inline-size: 3rem;
205
+ }
206
+
207
+ /* M3 Expressive selection emphasis. Increasing the button's internal
208
+ * space (instead of scaling the host) keeps labels and icons crisp and
209
+ * lets the existing padding transition transfer width smoothly. */
210
+ :host([variant='standard'][size='xsmall']) ::slotted(moni-button[active]),
211
+ :host([variant='standard'][size='xsmall']) ::slotted(moni-button[data-group-pressed]) {
212
+ --moni-button-padding: 0 1.25rem;
213
+ }
214
+ :host([variant='standard'][size='small']) ::slotted(moni-button[active]),
215
+ :host([variant='standard'][size='small']) ::slotted(moni-button[data-group-pressed]) {
216
+ --moni-button-padding: 0 1.75rem;
217
+ }
218
+ :host([variant='standard'][size='medium']) ::slotted(moni-button[active]),
219
+ :host([variant='standard'][size='medium']) ::slotted(moni-button[data-group-pressed]) {
220
+ --moni-button-padding: 0 2.375rem;
221
+ }
222
+ :host([variant='standard'][size='large']) ::slotted(moni-button[active]),
223
+ :host([variant='standard'][size='large']) ::slotted(moni-button[data-group-pressed]) {
224
+ --moni-button-padding: 0 4rem;
225
+ }
226
+ :host([variant='standard'][size='xlarge']) ::slotted(moni-button[active]),
227
+ :host([variant='standard'][size='extra']) ::slotted(moni-button[active]),
228
+ :host([variant='standard'][size='xlarge']) ::slotted(moni-button[data-group-pressed]),
229
+ :host([variant='standard'][size='extra']) ::slotted(moni-button[data-group-pressed]) {
230
+ --moni-button-padding: 0 5.25rem;
231
+ }
232
+
233
+ :host([variant='standard'][size='xsmall']) ::slotted(moni-button[data-group-adjacent-pressed]) { --moni-button-padding: 0 .5rem; }
234
+ :host([variant='standard'][size='small']) ::slotted(moni-button[data-group-adjacent-pressed]) { --moni-button-padding: 0 .75rem; }
235
+ :host([variant='standard'][size='medium']) ::slotted(moni-button[data-group-adjacent-pressed]) { --moni-button-padding: 0 1.25rem; }
236
+ :host([variant='standard'][size='large']) ::slotted(moni-button[data-group-adjacent-pressed]) { --moni-button-padding: 0 2.75rem; }
237
+ :host([variant='standard'][size='xlarge']) ::slotted(moni-button[data-group-adjacent-pressed]),
238
+ :host([variant='standard'][size='extra']) ::slotted(moni-button[data-group-adjacent-pressed]) { --moni-button-padding: 0 3.75rem; }
239
+
240
+ /* A selected standard button borrows space from its immediate
241
+ * neighbours, keeping the group's outside footprint stable. */
242
+ :host([variant='standard'][size='xsmall']) ::slotted(moni-button[data-group-adjacent-selected='shared']) { --moni-button-padding: 0 .5rem; }
243
+ :host([variant='standard'][size='xsmall']) ::slotted(moni-button[data-group-adjacent-selected='sole']) { --moni-button-padding: 0 .25rem; }
244
+ :host([variant='standard'][size='small']) ::slotted(moni-button[data-group-adjacent-selected='shared']) { --moni-button-padding: 0 .625rem; }
245
+ :host([variant='standard'][size='small']) ::slotted(moni-button[data-group-adjacent-selected='sole']) { --moni-button-padding: 0 .25rem; }
246
+ :host([variant='standard'][size='medium']) ::slotted(moni-button[data-group-adjacent-selected='shared']) { --moni-button-padding: 0 1.0625rem; }
247
+ :host([variant='standard'][size='medium']) ::slotted(moni-button[data-group-adjacent-selected='sole']) { --moni-button-padding: 0 .625rem; }
248
+ :host([variant='standard'][size='large']) ::slotted(moni-button[data-group-adjacent-selected='shared']) { --moni-button-padding: 0 2.5rem; }
249
+ :host([variant='standard'][size='large']) ::slotted(moni-button[data-group-adjacent-selected='sole']) { --moni-button-padding: 0 2rem; }
250
+ :host([variant='standard'][size='xlarge']) ::slotted(moni-button[data-group-adjacent-selected='shared']),
251
+ :host([variant='standard'][size='extra']) ::slotted(moni-button[data-group-adjacent-selected='shared']) { --moni-button-padding: 0 3.375rem; }
252
+ :host([variant='standard'][size='xlarge']) ::slotted(moni-button[data-group-adjacent-selected='sole']),
253
+ :host([variant='standard'][size='extra']) ::slotted(moni-button[data-group-adjacent-selected='sole']) { --moni-button-padding: 0 2.75rem; }
162
254
  `
163
255
  ];
164
256
 
@@ -169,7 +261,7 @@ export class MoniButtonGroup extends MoniElement {
169
261
  */
170
262
  protected override updated(changedProperties: Map<string | number | symbol, unknown>) {
171
263
  super.updated(changedProperties);
172
- if (changedProperties.has('variant') || changedProperties.has('size') || changedProperties.has('gap')) {
264
+ if (changedProperties.has('variant') || changedProperties.has('size') || changedProperties.has('gap') || changedProperties.has('shape')) {
173
265
  this.updateChildren();
174
266
  }
175
267
  }
@@ -183,6 +275,27 @@ export class MoniButtonGroup extends MoniElement {
183
275
  this.updateChildren();
184
276
  }
185
277
 
278
+ private getButtons() {
279
+ return (this.slottedButtons ?? []).filter(
280
+ (el) => el.tagName.toLowerCase() === 'moni-button' || el.tagName.toLowerCase() === 'moni-icon-button'
281
+ );
282
+ }
283
+
284
+ /** Keeps selection emphasis inside the group's original visual footprint.
285
+ * The selected width is funded evenly by every unselected button so edge
286
+ * selections remain visually balanced instead of collapsing one neighbour. */
287
+ private updateSelectionNeighbors() {
288
+ const buttons = this.getButtons();
289
+ buttons.forEach((button) => button.removeAttribute('data-group-adjacent-selected'));
290
+ if (this.variant !== 'standard') return;
291
+
292
+ const selected = buttons.filter((button) => button.hasAttribute('active'));
293
+ if (selected.length === 0) return;
294
+ const unselected = buttons.filter((button) => !button.hasAttribute('active'));
295
+ const weight = unselected.length === 1 ? 'sole' : 'shared';
296
+ unselected.forEach((button) => button.setAttribute('data-group-adjacent-selected', weight));
297
+ }
298
+
186
299
  private getGapValue(gap: string): string {
187
300
  if (!gap) return '';
188
301
  const preset = gap.toLowerCase();
@@ -202,9 +315,7 @@ export class MoniButtonGroup extends MoniElement {
202
315
  * correspondiente (`left-round-flat`, `inner-round`, etc.) para formar una cápsula unificada.
203
316
  */
204
317
  private updateChildren() {
205
- const buttons = this.slottedButtons.filter(
206
- (el) => el.tagName.toLowerCase() === 'moni-button' || el.tagName.toLowerCase() === 'moni-icon-button'
207
- );
318
+ const buttons = this.getButtons();
208
319
 
209
320
  const normalizedGap = this.gap.trim().toLowerCase();
210
321
  const hasConnectedGap = !['0', '0px', '0rem', '0em'].includes(normalizedGap);
@@ -214,7 +325,9 @@ export class MoniButtonGroup extends MoniElement {
214
325
  btn.setAttribute('size', this.size);
215
326
 
216
327
  // Propagate shape for connected variant
217
- if (this.variant === 'connected') {
328
+ if (this.variant === 'connected' && this.shape === 'square') {
329
+ btn.setAttribute('shape', 'square');
330
+ } else if (this.variant === 'connected') {
218
331
  if (hasConnectedGap) {
219
332
  if (buttons.length === 1) {
220
333
  btn.setAttribute('shape', 'round');
@@ -237,13 +350,10 @@ export class MoniButtonGroup extends MoniElement {
237
350
  }
238
351
  }
239
352
  } else {
240
- // Standard variant: buttons retain their own shapes or default round
241
- const currentShape = btn.getAttribute('shape');
242
- if (!currentShape || ['left-round-flat', 'right-round-flat', 'no-round', 'left-round', 'right-round', 'inner-round'].includes(currentShape)) {
243
- btn.setAttribute('shape', 'round');
244
- }
353
+ btn.setAttribute('shape', this.shape);
245
354
  }
246
355
  });
356
+ this.updateSelectionNeighbors();
247
357
  }
248
358
 
249
359
  /**
@@ -258,25 +368,13 @@ export class MoniButtonGroup extends MoniElement {
258
368
  const button = target.closest('moni-button, moni-icon-button') as HTMLElement;
259
369
  if (!button || button.hasAttribute('disabled')) return;
260
370
 
261
- const buttons = this.slottedButtons.filter(
262
- (el) => el.tagName.toLowerCase() === 'moni-button' || el.tagName.toLowerCase() === 'moni-icon-button'
263
- );
371
+ const buttons = this.getButtons();
264
372
  const index = buttons.indexOf(button);
265
373
  if (index === -1) return;
266
374
 
267
- const prev = buttons[index - 1];
268
- const next = buttons[index + 1];
269
-
270
- if (prev) {
271
- prev.style.transition = 'transform 200ms cubic-bezier(0.2, 0, 0, 1)';
272
- prev.style.transform = 'translateX(-6px) scaleX(0.92)';
273
- prev.style.transformOrigin = 'right center';
274
- }
275
- if (next) {
276
- next.style.transition = 'transform 200ms cubic-bezier(0.2, 0, 0, 1)';
277
- next.style.transform = 'translateX(6px) scaleX(0.92)';
278
- next.style.transformOrigin = 'left center';
279
- }
375
+ button.setAttribute('data-group-pressed', '');
376
+ buttons[index - 1]?.setAttribute('data-group-adjacent-pressed', '');
377
+ buttons[index + 1]?.setAttribute('data-group-adjacent-pressed', '');
280
378
  }
281
379
 
282
380
  /**
@@ -285,12 +383,10 @@ export class MoniButtonGroup extends MoniElement {
285
383
  * a su estado nativo neutro.
286
384
  */
287
385
  private handlePointerUp() {
288
- const buttons = this.slottedButtons.filter(
289
- (el) => el.tagName.toLowerCase() === 'moni-button' || el.tagName.toLowerCase() === 'moni-icon-button'
290
- );
386
+ const buttons = this.getButtons();
291
387
  buttons.forEach((btn) => {
292
- btn.style.transform = '';
293
- btn.style.transformOrigin = '';
388
+ btn.removeAttribute('data-group-pressed');
389
+ btn.removeAttribute('data-group-adjacent-pressed');
294
390
  });
295
391
  }
296
392
 
@@ -307,9 +403,7 @@ export class MoniButtonGroup extends MoniElement {
307
403
  return;
308
404
  }
309
405
 
310
- const buttons = this.slottedButtons.filter(
311
- (el) => el.tagName.toLowerCase() === 'moni-button' || el.tagName.toLowerCase() === 'moni-icon-button'
312
- );
406
+ const buttons = this.getButtons();
313
407
 
314
408
  if (!this.multi) {
315
409
  buttons.forEach((btn) => {
@@ -322,6 +416,8 @@ export class MoniButtonGroup extends MoniElement {
322
416
 
323
417
  // Toggle clicked button active state
324
418
  const wasActive = clickedButton.hasAttribute('active');
419
+ const activeCount = buttons.filter((btn) => btn.hasAttribute('active')).length;
420
+ if (wasActive && this.selectionRequired && activeCount <= 1) return;
325
421
  if (wasActive) {
326
422
  clickedButton.removeAttribute('active');
327
423
  (clickedButton as any).active = false;
@@ -203,6 +203,7 @@ export class MoniButton extends MoniElement {
203
203
  .button {
204
204
  box-sizing: var(--moni-button-box-sizing, border-box);
205
205
  display: inline-flex;
206
+ inline-size: var(--moni-button-inline-size, auto);
206
207
  --_button-visual-height: 3.5rem;
207
208
  align-items: center;
208
209
  justify-content: center;
@@ -57,4 +57,13 @@ describe('moni-loading-indicator', () => {
57
57
  expect(cssText).toContain('prefers-reduced-motion: reduce');
58
58
  expect(cssText).toContain('animation: none');
59
59
  });
60
+
61
+ it('permite que el indicador contained crezca fuera de su superficie', () => {
62
+ const cssText = ((el.constructor as typeof HTMLElement & { styles: unknown[] }).styles)
63
+ .map((style: any) => style?.cssText ?? '')
64
+ .join(' ');
65
+ expect(cssText).not.toContain('contain: strict');
66
+ expect(cssText).toContain('contain: layout style');
67
+ expect(cssText).toContain('overflow: visible');
68
+ });
60
69
  });
@@ -101,7 +101,11 @@ export class MoniLoadingIndicator extends MoniElement {
101
101
  :host {
102
102
  display: inline-block;
103
103
  aspect-ratio: 1 / 1;
104
- contain: strict;
104
+ /* Paint containment clipped oversized morphs in the contained
105
+ * variant. Keep layout/style isolation while allowing the active
106
+ * indicator to animate beyond the container surface. */
107
+ contain: layout style;
108
+ overflow: visible;
105
109
  vertical-align: middle;
106
110
  }
107
111
 
@@ -133,6 +137,19 @@ export class MoniLoadingIndicator extends MoniElement {
133
137
  justify-content: center;
134
138
  border-radius: var(--moni-loading-indicator-container-shape, 9999px);
135
139
  box-sizing: border-box;
140
+ overflow: visible;
141
+ }
142
+
143
+ .active-indicator-wrapper {
144
+ overflow: visible;
145
+ }
146
+
147
+ .active-indicator-scale {
148
+ display: flex;
149
+ align-items: center;
150
+ justify-content: center;
151
+ overflow: visible;
152
+ transform-origin: center;
136
153
  }
137
154
 
138
155
  .active-indicator {
@@ -243,7 +260,9 @@ export class MoniLoadingIndicator extends MoniElement {
243
260
  return html`
244
261
  <div class="container animate" aria-hidden="true">
245
262
  <div class="active-indicator-wrapper">
246
- <div class="active-indicator"></div>
263
+ <div class="active-indicator-scale">
264
+ <div class="active-indicator"></div>
265
+ </div>
247
266
  </div>
248
267
  </div>
249
268
  `;
@@ -318,17 +318,19 @@ export class MoniTimePicker extends MoniElement {
318
318
  }
319
319
 
320
320
  :host([orientation="horizontal"]) {
321
- inline-size: auto;
321
+ inline-size: min(40rem, 100%);
322
322
  }
323
323
  :host([orientation="horizontal"]) .main-content {
324
324
  flex-direction: row;
325
325
  align-items: center;
326
+ justify-content: center;
326
327
  gap: 2rem;
327
328
  }
328
329
  :host([orientation="horizontal"]) .left-pane {
329
330
  display: flex;
330
331
  flex-direction: column;
331
332
  justify-content: center;
333
+ flex: none;
332
334
  }
333
335
 
334
336
  /* Main Content (Default vertical) */
@@ -358,6 +360,7 @@ export class MoniTimePicker extends MoniElement {
358
360
  :host(:not([orientation])) .main-content {
359
361
  flex-direction: row;
360
362
  align-items: center;
363
+ justify-content: center;
361
364
  gap: 2rem;
362
365
  }
363
366
  :host([orientation='auto']) .left-pane,
@@ -365,6 +368,7 @@ export class MoniTimePicker extends MoniElement {
365
368
  display: flex;
366
369
  flex-direction: column;
367
370
  justify-content: center;
371
+ flex: none;
368
372
  }
369
373
  }
370
374