DEV: <DSelect /> (#30224)

`<DSelect />` is a wrapper similar to our existing `<DButton />` over the html element `<select>`. The code is ported from form kit which is now directly using `<DSelect />`. Note this component has also been used in edit topic timer modal.

This component is recommended for a small list of text items (no icons, no rich formatting...).

Usage:

```gjs
<DSelect class="my-select" @onChange={{this.handleChange}} as |select|>
  <select.Option @value="foo" class="my-favorite-option">Foo</select.Option>
  <select.Option @value="bar">Bar</select.Option>
</DSelect>
```

This commit comes with a set of assertions:

```gjs
import dselect from "discourse/tests/helpers/d-select-helper";
import { select } from "@ember/test-helpers";

assert
  .dselect(".my-select")
  .hasOption({ value: "bar", label: "Bar" })
  .hasOption({ value: "foo", label: "Foo" })
  .hasNoOption("baz");

await select(".my-select", "foo");

assert.dselect(".my-select").hasSelectedOption({value: "foo", label: "Foo"});
```
This commit is contained in:
Joffrey JAFFEUX 2024-12-13 10:40:06 +01:00 committed by GitHub
parent 622eb9e51c
commit cbc0ece6e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 310 additions and 176 deletions

View File

@ -0,0 +1,67 @@
import Component from "@glimmer/component";
import { hash } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { isNone } from "@ember/utils";
import { eq } from "truth-helpers";
import { i18n } from "discourse-i18n";
export const NO_VALUE_OPTION = "__NONE__";
export class DSelectOption extends Component {
get value() {
return isNone(this.args.value) ? NO_VALUE_OPTION : this.args.value;
}
<template>
{{! https://github.com/emberjs/ember.js/issues/19115 }}
{{#if (eq @selected @value)}}
<option
class="d-select__option --selected"
value={{this.value}}
selected
...attributes
>
{{yield}}
</option>
{{else}}
<option class="d-select__option" value={{this.value}} ...attributes>
{{yield}}
</option>
{{/if}}
</template>
}
export default class DSelect extends Component {
@action
handleInput(event) {
// if an option has no value, event.target.value will be the content of the option
// this is why we use this magic value to represent no value
this.args.onChange(
event.target.value === NO_VALUE_OPTION ? undefined : event.target.value
);
}
get hasSelectedValue() {
return this.args.value && this.args.value !== NO_VALUE_OPTION;
}
<template>
<select
value={{@value}}
...attributes
class="d-select"
{{on "input" this.handleInput}}
>
<DSelectOption @value={{NO_VALUE_OPTION}}>
{{#if this.hasSelectedValue}}
{{i18n "none_placeholder"}}
{{else}}
{{i18n "select_placeholder"}}
{{/if}}
</DSelectOption>
{{yield (hash Option=(component DSelectOption selected=@value))}}
</select>
</template>
}

View File

@ -1,11 +1,15 @@
<form>
<div class="control-group">
<ComboBox
@onChange={{@onChangeStatusType}}
@content={{@timerTypes}}
<DSelect
@value={{this.statusType}}
class="timer-type"
/>
@onChange={{@onChangeStatusType}}
as |select|
>
{{#each @timerTypes as |timer|}}
<select.Option @value={{timer.id}}>{{timer.name}}</select.Option>
{{/each}}
</DSelect>
</div>
{{#if this.publishToCategory}}

View File

@ -1,46 +1,29 @@
import Component from "@glimmer/component";
import { hash } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { NO_VALUE_OPTION } from "discourse/form-kit/lib/constants";
import { i18n } from "discourse-i18n";
import FKControlSelectOption from "./select/option";
import DSelect, { DSelectOption } from "discourse/components/d-select";
const SelectOption = <template>
<DSelectOption
@value={{@value}}
@selected={{@selected}}
class="form-kit__control-option"
>
{{yield}}
</DSelectOption>
</template>;
export default class FKControlSelect extends Component {
static controlType = "select";
@action
handleInput(event) {
// if an option has no value, event.target.value will be the content of the option
// this is why we use this magic value to represent no value
this.args.field.set(
event.target.value === NO_VALUE_OPTION ? undefined : event.target.value
);
}
get hasSelectedValue() {
return this.args.field.value && this.args.field.value !== NO_VALUE_OPTION;
}
<template>
<select
value={{@field.value}}
disabled={{@field.disabled}}
...attributes
<DSelect
class="form-kit__control-select"
{{on "input" this.handleInput}}
disabled={{@field.disabled}}
@value={{@field.value}}
@onChange={{@field.set}}
...attributes
>
<FKControlSelectOption @value={{NO_VALUE_OPTION}}>
{{#if this.hasSelectedValue}}
{{i18n "form_kit.select.none_placeholder"}}
{{else}}
{{i18n "form_kit.select.select_placeholder"}}
{{/if}}
</FKControlSelectOption>
{{yield
(hash Option=(component FKControlSelectOption selected=@field.value))
}}
</select>
{{yield (hash Option=(component SelectOption selected=@field.value))}}
</DSelect>
</template>
}

View File

@ -1,32 +0,0 @@
import Component from "@glimmer/component";
import { isNone } from "@ember/utils";
import { eq } from "truth-helpers";
import { NO_VALUE_OPTION } from "discourse/form-kit/lib/constants";
export default class FKControlSelectOption extends Component {
get value() {
return isNone(this.args.value) ? NO_VALUE_OPTION : this.args.value;
}
<template>
{{! https://github.com/emberjs/ember.js/issues/19115 }}
{{#if (eq @selected @value)}}
<option
class="form-kit__control-option --selected"
value={{this.value}}
selected
...attributes
>
{{yield}}
</option>
{{else}}
<option
class="form-kit__control-option"
value={{this.value}}
...attributes
>
{{yield}}
</option>
{{/if}}
</template>
}

View File

@ -1,4 +1,4 @@
import { click, fillIn, visit } from "@ember/test-helpers";
import { click, fillIn, select, visit } from "@ember/test-helpers";
import { test } from "qunit";
import topicFixtures from "discourse/tests/fixtures/topic";
import {
@ -48,7 +48,6 @@ acceptance("Topic - Edit timer", function (needs) {
await visit("/t/internationalization-localization");
await click(".toggle-admin-menu");
await click(".admin-topic-timer-update button");
await click("#tap_tile_start_of_next_business_week");
assert
@ -62,7 +61,6 @@ acceptance("Topic - Edit timer", function (needs) {
await visit("/t/internationalization-localization");
await click(".toggle-admin-menu");
await click(".admin-topic-timer-update button");
await click("#tap_tile_start_of_next_business_week");
assert
@ -76,13 +74,12 @@ acceptance("Topic - Edit timer", function (needs) {
.dom(".edit-topic-timer-modal .topic-timer-info")
.matchesText(/will automatically close in/);
const timerType = selectKit(".select-kit.timer-type");
await timerType.expand();
await timerType.selectRowByValue("close_after_last_post");
await select(".timer-type", "close_after_last_post");
const interval = selectKit(".select-kit.relative-time-intervals");
await interval.expand();
await interval.selectRowByValue("hours");
assert.strictEqual(interval.header().label(), "hours");
await fillIn(".relative-time-duration", "2");
@ -115,15 +112,11 @@ acceptance("Topic - Edit timer", function (needs) {
test("close temporarily", async function (assert) {
updateCurrentUser({ moderator: true });
const timerType = selectKit(".select-kit.timer-type");
await visit("/t/internationalization-localization");
await click(".toggle-admin-menu");
await click(".admin-topic-timer-update button");
await timerType.expand();
await timerType.selectRowByValue("open");
await select(".timer-type", "open");
await click("#tap_tile_start_of_next_business_week");
assert
@ -140,15 +133,12 @@ acceptance("Topic - Edit timer", function (needs) {
test("schedule publish to category - visible for a PM", async function (assert) {
updateCurrentUser({ moderator: true });
const timerType = selectKit(".select-kit.timer-type");
const categoryChooser = selectKit(".d-modal__body .category-chooser");
await visit("/t/pm-for-testing/12");
await click(".toggle-admin-menu");
await click(".admin-topic-timer-update button");
await timerType.expand();
await timerType.selectRowByValue("publish_to_category");
await select(".timer-type", "publish_to_category");
const categoryChooser = selectKit(".d-modal__body .category-chooser");
assert.strictEqual(categoryChooser.header().label(), "category…");
assert.strictEqual(categoryChooser.header().value(), null);
@ -174,16 +164,13 @@ acceptance("Topic - Edit timer", function (needs) {
test("schedule publish to category - visible for a private category", async function (assert) {
updateCurrentUser({ moderator: true });
const timerType = selectKit(".select-kit.timer-type");
const categoryChooser = selectKit(".d-modal__body .category-chooser");
// has private category id 24 (shared drafts)
await visit("/t/some-topic/9");
await click(".toggle-admin-menu");
await click(".admin-topic-timer-update button");
await timerType.expand();
await timerType.selectRowByValue("publish_to_category");
await select(".timer-type", "publish_to_category");
const categoryChooser = selectKit(".d-modal__body .category-chooser");
assert.strictEqual(categoryChooser.header().label(), "category…");
assert.strictEqual(categoryChooser.header().value(), null);
@ -209,8 +196,6 @@ acceptance("Topic - Edit timer", function (needs) {
test("schedule publish to category - visible for an unlisted public topic", async function (assert) {
updateCurrentUser({ moderator: true });
const timerType = selectKit(".select-kit.timer-type");
const categoryChooser = selectKit(".d-modal__body .category-chooser");
await visit("/t/internationalization-localization/280");
@ -221,8 +206,8 @@ acceptance("Topic - Edit timer", function (needs) {
await click(".toggle-admin-menu");
await click(".admin-topic-timer-update button");
await timerType.expand();
await timerType.selectRowByValue("publish_to_category");
await select(".timer-type", "publish_to_category");
const categoryChooser = selectKit(".d-modal__body .category-chooser");
assert.strictEqual(categoryChooser.header().label(), "category…");
assert.strictEqual(categoryChooser.header().value(), null);
@ -278,17 +263,17 @@ acceptance("Topic - Edit timer", function (needs) {
test("schedule publish to category - does not show for a public topic", async function (assert) {
updateCurrentUser({ moderator: true });
const timerType = selectKit(".select-kit.timer-type");
await visit("/t/internationalization-localization");
await click(".toggle-admin-menu");
await click(".admin-topic-timer-update button");
await timerType.expand();
assert.false(
timerType.rowByValue("publish_to_category").exists(),
"publish to category is not shown for a public topic"
);
assert
.dselect(".timer-type")
.hasNoOption(
"publish_to_category",
"publish to category is not shown for a public topic"
);
});
test("TL4 can't auto-delete", async function (assert) {
@ -298,11 +283,7 @@ acceptance("Topic - Edit timer", function (needs) {
await click(".toggle-admin-menu");
await click(".admin-topic-timer-update button");
const timerType = selectKit(".select-kit.timer-type");
await timerType.expand();
assert.false(timerType.rowByValue("delete").exists());
assert.dselect(".timer-type").hasNoOption("delete");
});
test("Category Moderator can auto-delete replies", async function (assert) {
@ -312,11 +293,10 @@ acceptance("Topic - Edit timer", function (needs) {
await click(".toggle-admin-menu");
await click(".admin-topic-timer-update button");
const timerType = selectKit(".select-kit.timer-type");
await timerType.expand();
assert.true(timerType.rowByValue("delete_replies").exists());
assert.dselect(".timer-type").hasOption({
value: "delete_replies",
label: i18n("topic.auto_delete_replies.title"),
});
});
test("TL4 can't auto-delete replies", async function (assert) {
@ -326,11 +306,7 @@ acceptance("Topic - Edit timer", function (needs) {
await click(".toggle-admin-menu");
await click(".admin-topic-timer-update button");
const timerType = selectKit(".select-kit.timer-type");
await timerType.expand();
assert.false(timerType.rowByValue("delete_replies").exists());
assert.dselect(".timer-type").hasNoOption("delete_replies");
});
test("Category Moderator can auto-delete", async function (assert) {
@ -340,24 +316,18 @@ acceptance("Topic - Edit timer", function (needs) {
await click(".toggle-admin-menu");
await click(".admin-topic-timer-update button");
const timerType = selectKit(".select-kit.timer-type");
await timerType.expand();
assert.true(timerType.rowByValue("delete").exists());
assert
.dselect(".timer-type")
.hasOption({ value: "delete", label: i18n("topic.auto_delete.title") });
});
test("auto delete", async function (assert) {
updateCurrentUser({ moderator: true });
const timerType = selectKit(".select-kit.timer-type");
await visit("/t/internationalization-localization");
await click(".toggle-admin-menu");
await click(".admin-topic-timer-update button");
await timerType.expand();
await timerType.selectRowByValue("delete");
await select(".timer-type", "delete");
await click("#tap_tile_two_weeks");
assert
@ -412,10 +382,7 @@ acceptance("Topic - Edit timer", function (needs) {
await visit("/t/internationalization-localization");
await click(".toggle-admin-menu");
await click(".admin-topic-timer-update button");
const timerType = selectKit(".select-kit.timer-type");
await timerType.expand();
await timerType.selectRowByValue("close_after_last_post");
await select(".timer-type", "close_after_last_post");
assert.dom(".topic-timer-heading").doesNotExist();
});

View File

@ -0,0 +1,60 @@
import { find } from "@ember/test-helpers";
import QUnit from "qunit";
class DSelect {
constructor(selector, context) {
this.context = context;
if (selector instanceof HTMLElement) {
this.element = selector;
} else {
this.element = find(selector);
}
}
hasOption({ value, label }, assertionMessage) {
const option = this.element.querySelector(
`.d-select__option[value="${value}"]`
);
this.context.dom(option).exists(assertionMessage);
this.context.dom(option).hasText(label, assertionMessage);
return this;
}
hasNoOption(value, assertionMessage) {
const option = this.element.querySelector(
`.d-select__option[value="${value}"]`
);
this.context.dom(option).doesNotExist(assertionMessage);
return this;
}
hasSelectedOption({ value, label }, assertionMessage) {
this.context
.dom(this.element.options[this.element.selectedIndex])
.hasText(label, assertionMessage);
this.context.dom(this.element).hasValue(value, assertionMessage);
return this;
}
hasNoSelectedOption({ value, label }, assertionMessage) {
this.context
.dom(this.element.options[this.element.selectedIndex])
.hasNoText(label, assertionMessage);
this.context.dom(this.element).hasNoValue(value, assertionMessage);
return this;
}
}
export function setupDSelectAssertions() {
QUnit.assert.dselect = function (selector = ".d-select") {
return new DSelect(selector, this);
};
}

View File

@ -103,6 +103,7 @@ import { resetNeedsHbrTopicList } from "discourse-common/lib/raw-templates";
import { clearResolverOptions } from "discourse-common/resolver";
import I18n from "discourse-i18n";
import { _clearSnapshots } from "select-kit/components/composer-actions";
import { setupDSelectAssertions } from "./d-select-assertions";
import { setupFormKitAssertions } from "./form-kit-assertions";
import { cleanupTemporaryModuleRegistrations } from "./temporary-module-helper";
@ -483,6 +484,7 @@ QUnit.assert.containsInstance = function (collection, klass, message) {
};
setupFormKitAssertions();
setupDSelectAssertions();
export async function selectDate(selector, date) {
const elem = document.querySelector(selector);

View File

@ -0,0 +1,68 @@
import { render, select } from "@ember/test-helpers";
import { module, test } from "qunit";
import DSelect, { NO_VALUE_OPTION } from "discourse/components/d-select";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { i18n } from "discourse-i18n";
module("Integration | Component | d-select", function (hooks) {
setupRenderingTest(hooks);
test("@onChange", async function (assert) {
const handleChange = (value) => {
assert.step(value);
};
await render(<template>
<DSelect @onChange={{handleChange}} as |s|>
<s.Option @value="foo">The real foo</s.Option>
</DSelect>
</template>);
await select(".d-select", "foo");
assert.verifySteps(["foo"]);
});
test("no value", async function (assert) {
await render(<template><DSelect /></template>);
assert.dselect().hasSelectedOption({
value: NO_VALUE_OPTION,
label: i18n("select_placeholder"),
});
});
test("selected value", async function (assert) {
await render(<template>
<DSelect @value="foo" as |s|>
<s.Option @value="foo">The real foo</s.Option>
</DSelect>
</template>);
assert.dselect().hasOption({
value: NO_VALUE_OPTION,
label: i18n("none_placeholder"),
});
assert.dselect().hasSelectedOption({
value: "foo",
label: "The real foo",
});
});
test("select attributes", async function (assert) {
await render(<template><DSelect class="test" /></template>);
assert.dom(".d-select.test").exists();
});
test("option attributes", async function (assert) {
await render(<template>
<DSelect as |s|>
<s.Option @value="foo" class="test">The real foo</s.Option>
</DSelect>
</template>);
assert.dom(".d-select__option.test").exists();
});
});

View File

@ -3,7 +3,6 @@ import { module, test } from "qunit";
import Form from "discourse/components/form";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import formKit from "discourse/tests/helpers/form-kit-helper";
import { i18n } from "discourse-i18n";
module(
"Integration | Component | FormKit | Controls | Select",
@ -38,7 +37,7 @@ module(
assert.deepEqual(data, { foo: "option-3" });
});
test("when disabled", async function (assert) {
test("@disabled", async function (assert) {
await render(<template>
<Form as |form|>
<form.Field @name="foo" @title="Foo" @disabled={{true}} as |field|>
@ -51,33 +50,5 @@ module(
assert.dom(".form-kit__control-select").hasAttribute("disabled");
});
test("no selection", async function (assert) {
await render(<template>
<Form as |form|>
<form.Field @name="foo" @title="Foo" as |field|>
<field.Select as |select|>
<select.Option @value="option-1">Option 1</select.Option>
</field.Select>
</form.Field>
</Form>
</template>);
assert
.dom(".form-kit__control-select option:nth-child(1)")
.hasText(
i18n("form_kit.select.select_placeholder"),
"it shows a placeholder for selection"
);
await formKit().field("foo").select("option-1");
assert
.dom(".form-kit__control-select option:nth-child(1)")
.hasText(
i18n("form_kit.select.none_placeholder"),
"it shows a placeholder for unselection"
);
});
}
);

View File

@ -6,6 +6,7 @@
@import "bookmark-modal";
@import "bookmark-menu";
@import "buttons";
@import "d-select";
@import "color-input";
@import "char-counter";
@import "conditional-loading-section";

View File

@ -0,0 +1,49 @@
.d-select {
width: 100%;
height: 2.25em;
background: var(--secondary);
border: 1px solid var(--primary-400);
border-radius: var(--d-input-border-radius);
box-sizing: border-box;
margin: 0;
appearance: none;
padding: 0 2em 0 0.5em !important;
appearance: none;
background-image: svg-uri(
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path fill='none' stroke='#{$primary-medium}' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m2 5 6 6 6-6'/></svg>"
);
background-repeat: no-repeat;
background-position: right 0.5rem center;
background-size: 16px 12px;
cursor: pointer;
&:focus,
&:focus-visible,
&:focus:focus-visible,
&:active {
//these importants are another great case for having a button element without that pesky default styling
&:not(:disabled) {
background-color: var(--secondary) !important;
color: var(--primary) !important;
border-color: var(--tertiary);
outline: 2px solid var(--tertiary);
outline-offset: -2px;
.d-icon {
color: inherit !important;
}
}
}
&:hover:not(:disabled) {
.discourse-no-touch & {
background-color: var(--secondary);
color: var(--primary);
border-color: var(--tertiary);
.d-icon {
color: inherit;
}
}
}
}

View File

@ -2,7 +2,6 @@
@include default-input;
z-index: 1;
margin: 0 !important;
width: 100% !important;
min-width: auto !important;
.form-kit__field.has-error & {

View File

@ -1,13 +1,8 @@
.form-kit__control-select {
@include default-input;
height: 2em;
border: 1px solid var(--primary-low-mid);
padding: 0 2em 0 0.5em !important;
appearance: none;
background-image: svg-uri(
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path fill='none' stroke='#{$primary-medium}' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m2 5 6 6 6-6'/></svg>"
);
background-repeat: no-repeat;
background-position: right 0.5rem center;
background-size: 16px 12px;
cursor: pointer;
@include breakpoint(mobile-large) {
height: 2.25em;
}
}

View File

@ -2,7 +2,7 @@
width: 100% !important;
height: 2em;
background: var(--secondary);
border: 1px solid var(--primary-low-mid);
border: 1px solid var(--primary-low-mid) !important;
border-radius: var(--d-input-border-radius);
padding: 0 0.5em !important;
box-sizing: border-box;

View File

@ -548,6 +548,9 @@ en:
switch_to_anon: "Enter Anonymous Mode"
switch_from_anon: "Exit Anonymous Mode"
select_placeholder: "Select…"
none_placeholder: "None"
banner:
close: "Dismiss this banner"
edit: "Edit"
@ -2206,9 +2209,6 @@ en:
optional: optional
errors_summary_title: "This form contains errors:"
dirty_form: "You didn't submit your changes! Are you sure you want to leave?"
select:
select_placeholder: "Select…"
none_placeholder: "None"
errors:
required: "Required"
invalid_url: "Must be a valid URL"