Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ jobs:
- run: pnpm install --frozen-lockfile

- name: Install Playwright browsers
run: pnpm dlx playwright install --with-deps chromium
run: pnpm exec playwright install --with-deps chromium

- name: Run tests
run: pnpm test
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"./EpNotification.js": "./dist/EpNotification.js",
"./EpToast.js": "./dist/EpToast.js",
"./EpToolbarSelect.js": "./dist/EpToolbarSelect.js",
"./EpSelect.js": "./dist/EpSelect.js",
"./EpUserBadge.js": "./dist/EpUserBadge.js",
"./EpTheme.js": "./dist/EpTheme.js",
"./EpEditor.js": "./dist/EpEditor.js",
Expand Down
249 changes: 249 additions & 0 deletions src/EpSelect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';

export interface EpSelectOption {
value: string;
label: string;
disabled?: boolean;
}

/**
* `<ep-select>` — a styled form select, matching Etherpad colibris's
* nice-select look (the plugin Etherpad uses to skin native `<select>`s).
*
* @fires ep-select:change - When a value is chosen. Detail: `{ value, label }`
*/
@customElement('ep-select')
export class EpSelect extends LitElement {
static styles = css`
:host {
--ep-font: var(--main-font-family, Quicksand, Cantarell, "Open Sans", "Helvetica Neue", sans-serif);
display: inline-block;
font-family: var(--ep-font);
font-size: 14px;
}

/* The box — base nice-select geometry + colibris colours. */
.nice-select {
position: relative;
box-sizing: border-box;
display: inline-block;
width: 100%;
min-width: 100px;
height: 28px;
line-height: 28px;
padding: 0 25px 0 10px;
border-radius: 3px;
border: 1px solid var(--bg-soft-color, #f2f3f4);
background-color: var(--bg-soft-color, #f2f3f4);
color: var(--text-color, #485365);
font-weight: bold;
cursor: pointer;
outline: none;
white-space: nowrap;
user-select: none;
transition: border-color 0.1s ease-in-out;
}
.nice-select:hover { border-color: var(--middle-color, #d2d2d2); }
.nice-select:focus-visible { border-color: var(--dark-color, #576273); }

.current { display: block; overflow: hidden; text-overflow: ellipsis; }
:host(:not([value])) .current,
.current.placeholder { color: var(--text-soft-color, #576273); font-weight: normal; }

/* Chevron */
.nice-select::after {
content: '';
position: absolute;
top: 50%;
right: 12px;
width: 5px;
height: 5px;
margin-top: -4px;
border-bottom: 2px solid var(--text-soft-color, #999);
border-right: 2px solid var(--text-soft-color, #999);
transform-origin: 66% 66%;
transform: rotate(45deg);
transition: transform 0.15s ease-in-out;
pointer-events: none;
}
:host([open]) .nice-select::after { transform: rotate(-135deg); }

/* Dropdown list */
.list {
list-style: none;
margin: 4px 0 0;
padding: 4px 0;
position: absolute;
top: 100%;
left: 0;
min-width: 100%;
box-sizing: border-box;
background-color: var(--bg-soft-color, #f2f3f4);
border-radius: 3px;
box-shadow: 0 0 0 1px rgba(68, 68, 68, 0.11), 0 8px 16px rgba(27, 39, 51, 0.12);
opacity: 0;
pointer-events: none;
max-height: 0;
overflow: auto;
z-index: 9;
transform-origin: 50% 0;
transform: scale(0.75) translateY(-12px);
transition: transform 0.2s cubic-bezier(0.5, 0, 0.08, 1.1), opacity 0.15s ease-out;
}
:host([open]) .list {
opacity: 1;
pointer-events: auto;
max-height: 260px;
transform: scale(1) translateY(0);
}

.option {
padding: 0 15px;
min-height: 35px;
line-height: 35px;
cursor: pointer;
white-space: nowrap;
font-weight: normal;
color: var(--text-color, #485365);
transition: background-color 0.2s;
}
.option:hover,
.option.focus,
.option.selected.focus { background-color: var(--bg-color, #ffffff); }
.option.selected { font-weight: bold; }
.option.disabled {
color: var(--text-soft-color, #999);
background-color: transparent;
cursor: default;
}

/* Disabled box */
:host([disabled]) .nice-select {
color: var(--text-soft-color, #999);
pointer-events: none;
opacity: 0.7;
}
:host([disabled]) .nice-select::after { border-color: var(--middle-color, #ccc); }
`;

@property({ type: Array }) options: EpSelectOption[] = [];
@property({ reflect: true }) value = '';
@property() placeholder = '';
@property() name = '';
@property({ type: Boolean, reflect: true }) disabled = false;
@property({ type: Boolean, reflect: true }) open = false;

@state() private _focusIndex = -1;

private _onDocClick = (e: MouseEvent) => {
if (this.open && !e.composedPath().includes(this)) this.open = false;
};

connectedCallback() {
super.connectedCallback();
document.addEventListener('click', this._onDocClick);
}

disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('click', this._onDocClick);
}

private get _selected(): EpSelectOption | undefined {
return this.options.find((o) => o.value === this.value);
}

private _toggle() {
if (this.disabled) return;
this.open = !this.open;
if (this.open) {
const sel = this.options.findIndex((o) => o.value === this.value);
this._focusIndex = sel >= 0 ? sel : this._firstEnabled();
}
}

private _firstEnabled(): number {
return this.options.findIndex((o) => !o.disabled);
}

private _select(opt: EpSelectOption, e?: Event) {
e?.stopPropagation();
if (opt.disabled) return;
this.value = opt.value;
this.open = false;
this.dispatchEvent(new CustomEvent('ep-select:change', {
detail: { value: opt.value, label: opt.label },
bubbles: true,
composed: true,
}));
}

private _move(dir: 1 | -1) {
const n = this.options.length;
let i = this._focusIndex;
for (let step = 0; step < n; step++) {
i = (i + dir + n) % n;
if (!this.options[i]?.disabled) { this._focusIndex = i; break; }
}
}

private _onKeydown(e: KeyboardEvent) {
if (this.disabled) return;
switch (e.key) {
case 'Enter':
case ' ':
e.preventDefault();
if (this.open && this._focusIndex >= 0) this._select(this.options[this._focusIndex]);
else this._toggle();
break;
Comment on lines +192 to +200
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Stale focusindex crash 🐞 Bug ≡ Correctness

EpSelect._onKeydown calls _select(this.options[this._focusIndex]) without checking the upper bound,
so if options changes while open, Enter/Space can pass undefined into _select() and throw when
reading opt.disabled. This can crash the component during keyboard interaction in dynamic UIs.
Agent Prompt
## Issue description
`EpSelect._onKeydown()` can call `_select()` with an out-of-bounds option (undefined) if `options` changes while the menu is open, leading to a runtime exception when `_select()` dereferences `opt.disabled`.

## Issue Context
`options` is a public reactive property and can be updated by consumers at any time. The repo's `EpDropdown` guards focus index bounds before dereferencing.

## Fix Focus Areas
- src/EpSelect.ts[171-181]
- src/EpSelect.ts[192-200]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

case 'ArrowDown':
e.preventDefault();
if (!this.open) this._toggle();
else this._move(1);
break;
case 'ArrowUp':
e.preventDefault();
if (this.open) this._move(-1);
break;
case 'Escape':
this.open = false;
break;
}
}

render() {
const sel = this._selected;
return html`
<div
class="nice-select"
part="select"
role="listbox"
aria-expanded="${this.open}"
tabindex="${this.disabled ? -1 : 0}"
@click="${this._toggle}"
@keydown="${this._onKeydown}"
>
<span class="current ${classMap({ placeholder: !sel })}">${sel ? sel.label : this.placeholder}</span>
<ul class="list" part="list" role="presentation">
${this.options.map((o, i) => html`
<li
class="option ${classMap({ selected: o.value === this.value, disabled: !!o.disabled, focus: i === this._focusIndex })}"
role="option"
aria-selected="${o.value === this.value}"
data-value="${o.value}"
@click="${(e: Event) => this._select(o, e)}"
>${o.label}</li>
`)}
</ul>
</div>
`;
}
}

declare global {
interface HTMLElementTagNameMap {
'ep-select': EpSelect;
}
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export { EpModal } from './EpModal.js';
export { EpNotification } from './EpNotification.js';
export { EpToastContainer, EpToastItem } from './EpToast.js';
export { EpToolbarSelect } from './EpToolbarSelect.js';
export { EpSelect } from './EpSelect.js';
export type { EpSelectOption } from './EpSelect.js';
export { EpUserBadge } from './EpUserBadge.js';
export { EpTheme, themes } from './EpTheme.js';
export type { ThemeTokens } from './EpTheme.js';
Expand Down
112 changes: 112 additions & 0 deletions stories/EpSelect.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { html } from 'lit';
import type { Meta, StoryObj } from '@storybook/web-components';
import { expect, userEvent, fn, waitFor } from 'storybook/test';
import '../src/EpSelect.js';

type EpSelectArgs = {
value: string;
placeholder: string;
disabled: boolean;
};

const OPTIONS = [
{ value: 'h1', label: 'Heading 1' },
{ value: 'h2', label: 'Heading 2' },
{ value: 'p', label: 'Paragraph' },
{ value: 'code', label: 'Code', disabled: true },
];

const meta: Meta<EpSelectArgs> = {
title: 'Components/EpSelect',
component: 'ep-select',
args: { value: '', placeholder: 'Select…', disabled: false },
};
export default meta;
type Story = StoryObj<EpSelectArgs>;

async function getSelect(canvasElement: HTMLElement) {
const host = canvasElement.querySelector('ep-select')! as any;
host.options = OPTIONS;
await host.updateComplete;
const root = host.shadowRoot!;
return {
host,
box: root.querySelector('.nice-select') as HTMLElement,
current: root.querySelector('.current') as HTMLElement,
list: root.querySelector('.list') as HTMLElement,
options: () => Array.from(root.querySelectorAll('.option')) as HTMLElement[],
};
}

export const Default: Story = {
render: (a) => html`<ep-select placeholder="${a.placeholder}"></ep-select>`,
play: async ({ canvasElement }) => {
const { box, current, options } = await getSelect(canvasElement);
await expect(box).not.toBe(null);
// shows the placeholder when nothing is selected
await expect(current.textContent?.trim()).toBe('Select…');
// renders one .option per provided option
await expect(options().length).toBe(OPTIONS.length);
},
};

export const OpensOnClick: Story = {
render: () => html`<ep-select placeholder="Select…"></ep-select>`,
play: async ({ canvasElement }) => {
const { host, box } = await getSelect(canvasElement);
await expect(host.hasAttribute('open')).toBe(false);
await userEvent.click(box);
await waitFor(() => expect(host.hasAttribute('open')).toBe(true));
},
};

export const SelectsOption: Story = {
render: () => html`<ep-select placeholder="Select…"></ep-select>`,
play: async ({ canvasElement }) => {
const { host, box, current, options } = await getSelect(canvasElement);
const handler = fn();
host.addEventListener('ep-select:change', handler);

await userEvent.click(box);
await userEvent.click(options()[1]); // Heading 2

await expect(host.value).toBe('h2');
await expect(current.textContent?.trim()).toBe('Heading 2');
await expect(host.hasAttribute('open')).toBe(false); // closes after select
await expect(handler).toHaveBeenCalledTimes(1);
await expect(handler.mock.calls[0][0].detail).toEqual({ value: 'h2', label: 'Heading 2' });
},
};

export const PreselectedValue: Story = {
render: () => html`<ep-select value="p"></ep-select>`,
play: async ({ canvasElement }) => {
const { current, options } = await getSelect(canvasElement);
await expect(current.textContent?.trim()).toBe('Paragraph');
const selected = options().find((o) => o.classList.contains('selected'));
await expect(selected?.textContent?.trim()).toBe('Paragraph');
},
};

export const DisabledDoesNotOpen: Story = {
render: () => html`<ep-select placeholder="Select…" disabled></ep-select>`,
play: async ({ canvasElement }) => {
const { host, box } = await getSelect(canvasElement);
// pointer-events:none already blocks real clicks; bypass that to confirm
// the JS guard keeps it closed too.
await userEvent.click(box, { pointerEventsCheck: 0 });
await expect(host.hasAttribute('open')).toBe(false);
},
};

export const DisabledOptionNotSelectable: Story = {
render: () => html`<ep-select placeholder="Select…"></ep-select>`,
play: async ({ canvasElement }) => {
const { host, box, options } = await getSelect(canvasElement);
await userEvent.click(box);
const codeOption = options().find((o) => o.textContent?.trim() === 'Code')!;
await expect(codeOption.classList.contains('disabled')).toBe(true);
await userEvent.click(codeOption);
await expect(host.value).toBe(''); // disabled option does not select
},
};
Loading