Skip to content

Commit c5d96ad

Browse files
committed
feat: add carousel mode for select bindings with keyboard navigation support
1 parent 0fb1634 commit c5d96ad

7 files changed

Lines changed: 203 additions & 8 deletions

RELEASE_NOTES.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
- **CSS custom properties**: All 18 token colors and 10 UI colors exposed as `--token-*` and `--code-*` variables for external customization.
1111
- **Editable zone styling**: Interactive controls expose `part="editable"` and 8 CSS custom properties (`--code-editable-text-decoration`, `--code-editable-border-radius`, `--code-editable-border`, `--code-editable-outline`, `--code-editable-background`, `--code-editable-padding`, etc.) for full decoration customization. Built-in styles: wavy (default), dotted, dashed, highlight, outline, pill, hand-drawn, none.
1212
- **Condition value matching**: Conditional textareas now support `condition="key=value"` syntax to show content when a binding equals a specific value (in addition to existing truthy/falsy checks).
13+
- **Select carousel mode**: New `carousel` boolean attribute on `<code-binding type="select">` cycles through options on click instead of opening a dropdown. Supports keyboard navigation (ArrowUp/ArrowDown).
1314

1415
### Bug Fixes
1516

@@ -34,8 +35,8 @@
3435

3536
### Tests
3637

37-
- Added 40 new tests: cleanup (3), XSS (3), conditional inline (1), copy button (8), line numbers (5), accessibility (6), condition value matching (5), part="editable" (5), editable zone CSS (4)
38-
- Updated tests: theme system (7), mixed content highlighting (7) — 164 tests total
38+
- Added 52 new tests: cleanup (3), XSS (3), conditional inline (1), copy button (8), line numbers (5), accessibility (6), condition value matching (5), part="editable" (5), editable zone CSS (4), carousel (6 rendering + 6 code-binding)
39+
- Updated tests: theme system (7), mixed content highlighting (7) — 177 tests total
3940

4041
---
4142

demo/index.html

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -541,7 +541,7 @@ <h2>Editable Zone Style</h2>
541541
--code-editable-padding: 2px 6px;</textarea>
542542
<textarea condition="editableStyle=none"> --code-editable-text-decoration: none;</textarea>
543543
<textarea>}</textarea>
544-
<code-binding key="editableStyle" type="select" options="wavy,dotted,dashed,highlight,outline,pill,hand-drawn,none" value="wavy"
544+
<code-binding key="editableStyle" type="select" carousel options="wavy,dotted,dashed,highlight,outline,pill,hand-drawn,none" value="wavy"
545545
onchange="applyEditableStyle(e.detail)"></code-binding>
546546
</interactive-code>
547547
</section>
@@ -666,14 +666,14 @@ <h3>String Type</h3>
666666

667667
<!-- Select Type -->
668668
<article class="doc-section">
669-
<h3>Select Type</h3>
670-
<p>2 options = toggle, 3+ options = dropdown:</p>
669+
<h3>Select Type — Dropdown or Carousel</h3>
670+
<p>2 options = toggle click, 3+ options = dropdown. Add <code>carousel</code> attribute to cycle through options on click instead:</p>
671671

672672
<div class="code-header"><span>Written by developer</span></div>
673673
<interactive-code class="inline-code" language="html" show-copy>
674674
<textarea><interactive-code language="scss" ${show-line-numbers} ${show-copy}>
675675
<textarea>.alert { background: '${color}'; }&lt;/textarea>
676-
<code-binding key="color" type="select" options="crimson,forestgreen,royalblue,orange" value="crimson"
676+
<code-binding key="color" type="select" ${carousel} options="crimson,forestgreen,royalblue,orange" value="crimson"
677677
onchange="document.getElementById('preview-alert').style.background = e.detail"></code-binding>
678678
</interactive-code>
679679

@@ -682,6 +682,8 @@ <h3>Select Type</h3>
682682
onchange="var el=this.closest('.doc-section').querySelector('.user-view interactive-code'); e.detail ? el.setAttribute('show-line-numbers','') : el.removeAttribute('show-line-numbers')"></code-binding>
683683
<code-binding key="show-copy" type="attribute" value="false"
684684
onchange="var el=this.closest('.doc-section').querySelector('.user-view interactive-code'); e.detail ? el.setAttribute('show-copy','') : el.removeAttribute('show-copy')"></code-binding>
685+
<code-binding key="carousel" type="attribute" value="false"
686+
onchange="var cb=this.closest('.doc-section').querySelector('.user-view code-binding[key=color]'); e.detail ? cb.setAttribute('carousel','') : cb.removeAttribute('carousel'); cb.dispatchEvent(new CustomEvent('change', {detail: cb.value, bubbles: true, composed: true}))"></code-binding>
685687
</interactive-code>
686688

687689
<div class="preview-wrapper">
@@ -1215,6 +1217,12 @@ <h3>API - &lt;code-binding&gt;</h3>
12151217
<td></td>
12161218
<td>Comma-separated options (for <code>select</code> type)</td>
12171219
</tr>
1220+
<tr>
1221+
<td><code>carousel</code></td>
1222+
<td><code>boolean</code></td>
1223+
<td><code>false</code></td>
1224+
<td>Cycle through options on click instead of dropdown (for <code>select</code> type)</td>
1225+
</tr>
12181226
</tbody>
12191227
</table>
12201228
</div>
@@ -1271,7 +1279,7 @@ <h3>Binding Types</h3>
12711279
<tr>
12721280
<td><code>select</code></td>
12731281
<td>Option from list</td>
1274-
<td>Click toggle (2 options) or dropdown (3+)</td>
1282+
<td>Click toggle (2 options), dropdown (3+), or carousel (<code>carousel</code> attribute)</td>
12751283
</tr>
12761284
<tr>
12771285
<td><code>color</code></td>

src/code-binding.element.spec.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,67 @@ describe('CodeBindingElement', () => {
456456
});
457457
});
458458

459+
describe('carousel getter', () => {
460+
it('should return false when carousel attribute is not set', () => {
461+
expect(element.carousel).toBe(false);
462+
});
463+
464+
it('should return true when carousel attribute is set', () => {
465+
element.setAttribute('carousel', '');
466+
expect(element.carousel).toBe(true);
467+
});
468+
});
469+
470+
describe('previous()', () => {
471+
it('should cycle to previous option in select', () => {
472+
element.setAttribute('type', 'select');
473+
element.setAttribute('options', 'a, b, c');
474+
element.setAttribute('value', 'b');
475+
document.body.appendChild(element);
476+
477+
element.previous();
478+
expect(element.value).toBe('a');
479+
480+
document.body.removeChild(element);
481+
});
482+
483+
it('should wrap around to last option when at first', () => {
484+
element.setAttribute('type', 'select');
485+
element.setAttribute('options', 'a, b, c');
486+
element.setAttribute('value', 'a');
487+
document.body.appendChild(element);
488+
489+
element.previous();
490+
expect(element.value).toBe('c');
491+
492+
document.body.removeChild(element);
493+
});
494+
495+
it('should not cycle when disabled', () => {
496+
element.setAttribute('type', 'select');
497+
element.setAttribute('options', 'a, b, c');
498+
element.setAttribute('value', 'b');
499+
element.setAttribute('disabled', '');
500+
document.body.appendChild(element);
501+
502+
element.previous();
503+
expect(element.value).toBe('b');
504+
505+
document.body.removeChild(element);
506+
});
507+
508+
it('should do nothing for non-select types', () => {
509+
element.setAttribute('type', 'number');
510+
element.setAttribute('value', '5');
511+
document.body.appendChild(element);
512+
513+
element.previous();
514+
expect(element.value).toBe(5);
515+
516+
document.body.removeChild(element);
517+
});
518+
});
519+
459520
describe('change event', () => {
460521
it('should bubble', () => {
461522
element.setAttribute('type', 'number');

src/code-binding.element.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export class CodeBindingElement extends HTMLElement {
4242
const v = this.getAttribute('options');
4343
return v ? v.split(',').map(s => s.trim()) : [];
4444
}
45+
get carousel(): boolean { return this.hasAttribute('carousel'); }
4546

4647
get value(): any { return this._value; }
4748
set value(v: any) {
@@ -135,6 +136,19 @@ export class CodeBindingElement extends HTMLElement {
135136
}
136137
}
137138

139+
/** Cycle to previous option (for select carousel) */
140+
previous() {
141+
if (this._disabled) return;
142+
if (this.type === 'select') {
143+
const opts = this.options;
144+
if (opts.length > 0) {
145+
const currentIndex = opts.indexOf(this._value);
146+
const prevIndex = (currentIndex - 1 + opts.length) % opts.length;
147+
this.value = opts[prevIndex];
148+
}
149+
}
150+
}
151+
138152
/** Increment number value */
139153
increment() {
140154
if (this._disabled) return;

src/interactive-code.element.spec.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1512,6 +1512,102 @@ describe('InteractiveCodeElement', () => {
15121512
});
15131513
});
15141514

1515+
describe('select carousel mode', () => {
1516+
it('should render select with carousel attribute as clickable span', async () => {
1517+
element.innerHTML = `
1518+
<textarea>\${color}</textarea>
1519+
<code-binding key="color" type="select" carousel options="red, green, blue" value="red"></code-binding>
1520+
`;
1521+
document.body.appendChild(element);
1522+
1523+
await new Promise(resolve => setTimeout(resolve, 150));
1524+
1525+
const carousel = element.shadowRoot?.querySelector('.inline-select-carousel');
1526+
expect(carousel).not.toBeNull();
1527+
expect(carousel?.getAttribute('data-action')).toBe('toggle');
1528+
// Should NOT render a dropdown
1529+
const select = element.shadowRoot?.querySelector('select');
1530+
expect(select).toBeNull();
1531+
});
1532+
1533+
it('should display current value in carousel', async () => {
1534+
element.innerHTML = `
1535+
<textarea>\${color}</textarea>
1536+
<code-binding key="color" type="select" carousel options="red, green, blue" value="green"></code-binding>
1537+
`;
1538+
document.body.appendChild(element);
1539+
1540+
await new Promise(resolve => setTimeout(resolve, 150));
1541+
1542+
const valueSpan = element.shadowRoot?.querySelector('.inline-select-carousel .token-string');
1543+
expect(valueSpan?.textContent).toBe('green');
1544+
});
1545+
1546+
it('should have role="button" and part="editable" on carousel', async () => {
1547+
element.innerHTML = `
1548+
<textarea>\${color}</textarea>
1549+
<code-binding key="color" type="select" carousel options="red, green, blue" value="red"></code-binding>
1550+
`;
1551+
document.body.appendChild(element);
1552+
1553+
await new Promise(resolve => setTimeout(resolve, 150));
1554+
1555+
const carousel = element.shadowRoot?.querySelector('.inline-select-carousel');
1556+
expect(carousel?.getAttribute('role')).toBe('button');
1557+
expect(carousel?.getAttribute('part')).toBe('editable');
1558+
});
1559+
1560+
it('should cycle value on click via toggle action', async () => {
1561+
element.innerHTML = `
1562+
<textarea>\${color}</textarea>
1563+
<code-binding key="color" type="select" carousel options="red, green, blue" value="red"></code-binding>
1564+
`;
1565+
document.body.appendChild(element);
1566+
1567+
await new Promise(resolve => setTimeout(resolve, 150));
1568+
1569+
const binding = element.querySelector('code-binding') as CodeBindingElement;
1570+
binding.toggle();
1571+
1572+
await new Promise(resolve => setTimeout(resolve, 50));
1573+
1574+
expect(binding.value).toBe('green');
1575+
const valueSpan = element.shadowRoot?.querySelector('.inline-select-carousel .token-string');
1576+
expect(valueSpan?.textContent).toBe('green');
1577+
});
1578+
1579+
it('should also work with 2 options when carousel is set', async () => {
1580+
element.innerHTML = `
1581+
<textarea>\${mode}</textarea>
1582+
<code-binding key="mode" type="select" carousel options="on, off" value="on"></code-binding>
1583+
`;
1584+
document.body.appendChild(element);
1585+
1586+
await new Promise(resolve => setTimeout(resolve, 150));
1587+
1588+
// Should render as carousel, not as toggle
1589+
const carousel = element.shadowRoot?.querySelector('.inline-select-carousel');
1590+
expect(carousel).not.toBeNull();
1591+
const toggle = element.shadowRoot?.querySelector('.inline-select-toggle');
1592+
expect(toggle).toBeNull();
1593+
});
1594+
1595+
it('should render as dropdown when carousel is not set (3+ options)', async () => {
1596+
element.innerHTML = `
1597+
<textarea>\${color}</textarea>
1598+
<code-binding key="color" type="select" options="red, green, blue" value="red"></code-binding>
1599+
`;
1600+
document.body.appendChild(element);
1601+
1602+
await new Promise(resolve => setTimeout(resolve, 150));
1603+
1604+
const select = element.shadowRoot?.querySelector('select');
1605+
expect(select).not.toBeNull();
1606+
const carousel = element.shadowRoot?.querySelector('.inline-select-carousel');
1607+
expect(carousel).toBeNull();
1608+
});
1609+
});
1610+
15151611
describe('editable zone CSS custom properties', () => {
15161612
it('should use --code-editable-text-decoration in CSS', () => {
15171613
document.body.appendChild(element);

src/interactive-code.element.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,12 @@ export class InteractiveCodeElement extends HTMLElement {
493493
} else if (event.key === 'ArrowDown' && binding.type === 'number') {
494494
event.preventDefault();
495495
binding.decrement();
496+
} else if (event.key === 'ArrowUp' && binding.type === 'select' && binding.carousel) {
497+
event.preventDefault();
498+
binding.toggle();
499+
} else if (event.key === 'ArrowDown' && binding.type === 'select' && binding.carousel) {
500+
event.preventDefault();
501+
binding.previous();
496502
}
497503
}
498504

@@ -797,6 +803,11 @@ export class InteractiveCodeElement extends HTMLElement {
797803

798804
case 'select': {
799805
const options = binding.options;
806+
// Carousel mode: click to cycle through all options
807+
if (binding.carousel) {
808+
return `<span class="inline-control inline-select-carousel${disabledClass}" part="editable" data-binding="${escKey}" data-action="toggle" role="button" tabindex="${tabindex}" aria-label="Cycle ${escKey}: ${escValue}">` +
809+
`<span class="token-string">${escValue}</span></span>`;
810+
}
800811
// For 2 options, render as toggle (like boolean)
801812
if (options.length === 2) {
802813
return `<span class="inline-control inline-select-toggle${disabledClass}" part="editable" data-binding="${escKey}" data-action="toggle" role="button" tabindex="${tabindex}" aria-label="Toggle ${escKey}: ${escValue}">` +
@@ -1127,7 +1138,8 @@ export class InteractiveCodeElement extends HTMLElement {
11271138
.inline-boolean,
11281139
.inline-number,
11291140
.inline-string,
1130-
.inline-select-toggle {
1141+
.inline-select-toggle,
1142+
.inline-select-carousel {
11311143
padding: var(--code-editable-padding, 0 4px);
11321144
position: relative;
11331145
}

types/code-binding.element.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export declare class CodeBindingElement extends HTMLElement {
2626
get max(): number | undefined;
2727
get step(): number | undefined;
2828
get options(): string[];
29+
get carousel(): boolean;
2930
get value(): any;
3031
set value(v: any);
3132
get disabled(): boolean;
@@ -36,6 +37,8 @@ export declare class CodeBindingElement extends HTMLElement {
3637
private emitChange;
3738
/** Toggle boolean/comment/attribute value or cycle through select options */
3839
toggle(): void;
40+
/** Cycle to previous option (for select carousel) */
41+
previous(): void;
3942
/** Increment number value */
4043
increment(): void;
4144
/** Decrement number value */

0 commit comments

Comments
 (0)