diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..a8ca29c --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,25 @@ +name: Publish + +on: + workflow_dispatch: {} + release: + types: [published] + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: 'https://registry.npmjs.org' + cache: 'yarn' + - run: yarn --frozen-lockfile + - run: yarn test + - run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8d71c65..7459d2f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,13 +7,14 @@ on: branches: [main] jobs: - build: + test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - node-version: 16 + node-version: 20 + registry-url: 'https://registry.npmjs.org' cache: 'yarn' - run: yarn --frozen-lockfile - run: | diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index cece985..51ead04 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -8,14 +8,14 @@ cd inputs yarn ``` -Inputs is written in ES modules and uses [Snowpack](https://snowpack.dev/) for development; this means that you can edit the Inputs source code and examples, and they’ll update live as you save changes. To start, copy over the example scratch.html file: +Inputs is written in ES modules and uses [Vite](https://vitejs.dev/) for development; this means that you can edit the Inputs source code and examples, and they’ll update live as you save changes. To start, copy over the example scratch.html file: ``` mkdir scratch cp test/scratch.html scratch/index.html ``` -Then start Snowpack: +Then start Vite: ``` yarn dev diff --git a/LICENSE b/LICENSE index 4ed4c68..821e01b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright 2021 Observable, Inc. +Copyright 2021–2024 Observable, Inc. Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice diff --git a/README.md b/README.md index 0a72565..eb70969 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,44 @@ # Observable Inputs -**Observable Inputs** are lightweight user interface components — buttons, sliders, dropdowns, tables, and the like — to help you explore data and build interactive displays in [Observable notebooks](https://observablehq.com). Each input can be used as an [Observable view](https://observablehq.com/@observablehq/introduction-to-views). For example, to allow a number *x* to be manipulated by a slider: +**Observable Inputs** are lightweight user interface components — buttons, sliders, dropdowns, tables, and the like — to help you explore data and build interactive displays. Each provided input implementation returns an HTML element that exposes a _value_ property that reflects the input’s current value, and emits an _input_ event when the current value changes. -```js -viewof x = Inputs.range([0, 100]) +Observable Inputs is a small, free, [open-source](./LICENSE) JavaScript library that can be used in any web environment. For example, here’s how you might let the user choose a number in a vanilla web page, loading Inputs and its stylesheet from the jsDelivr CDN: + +```html + + + + ``` -Now you can reference the live value of *x* in any cell, *e.g.*: +Observable Inputs are designed to be especially convenient in [Observable Framework](https://observablehq.com/framework) and [Observable notebooks](https://observablehq.com/). In Framework, use the *view* function to display an input and expose its value as a top-level [reactive variable](https://observablehq.com/framework/reactivity): ```js -md`The value of *x* is ${x}.` +const x = view(Inputs.range([0, 100])); ``` -Any cell that references *x* will run automatically when the *viewof x* slider is moved. For live examples, see: +In Observable notebooks, you can similarly use the [`viewof`](https://observablehq.com/@observablehq/introduction-to-views) operator: + +```js +viewof x = Inputs.range([0, 100]) +``` -https://observablehq.com/@observablehq/inputs +In either case, *x* now refers reactively to the current value of the input; code that references *x* will re-run automatically when the user interacts with the input. Observable Inputs provides basic inputs: @@ -41,34 +65,32 @@ Lastly, Inputs provides low-level utilities for more advanced usage: * [bind](#inputsbindtarget-source-invalidation) - synchronize two or more inputs * [disposal](#inputsdisposalelement) - detect when an input is discarded -Observable Inputs are released under the [ISC license](./LICENSE) and depend only on [Hypertext Literal](https://github.com/observablehq/htl), our tagged template literal for safely generating dynamic HTML. - ## Inputs ### Button #### Inputs.button(*content*, *options*) -[A Button labeled OK](https://observablehq.com/@observablehq/input-button) +[A Button labeled OK](https://observablehq.com/framework/inputs/button) ```js -viewof clicks = Inputs.button("OK", {label: "Click me"}) +Inputs.button("OK", {label: "Click me"}) ``` -[Source](./src/button.js) · [Examples](https://observablehq.com/@observablehq/input-button) · A Button emits an *input* event when you click it. Buttons may be used to trigger the evaluation of cells, say to restart an animation. The given *content*, either a string or an HTML element, is displayed within the button. If *content* is not specified, it defaults to “≡”, but a more meaningful value is strongly encouraged for usability. +[Source](./src/button.js) · [Examples](https://observablehq.com/framework/inputs/button) · [Notebook](https://observablehq.com/@observablehq/input-button) · A Button emits an *input* event when you click it. Buttons may be used to trigger the evaluation of cells, say to restart an animation. The given *content*, either a string or an HTML element, is displayed within the button. If *content* is not specified, it defaults to “≡”, but a more meaningful value is strongly encouraged for usability. By default, the value of a solitary Button (when *content* is a string or HTML) is how many times it has been clicked. The *reduce* function allows you to compute the new value of the Button when clicked, given the old value. For example, to set the value as the time of last click: ```js -viewof time = Inputs.button("Refresh", {value: null, reduce: () => Date.now()}) +Inputs.button("Refresh", {value: null, reduce: () => Date.now()}) ``` If *content* is an array or iterable, then multiple buttons will be generated. Each element in *content* should be a tuple [*contenti*, *reducei*], where *contenti* is the content for the given button (a string or HTML), and *reducei* is the function to call when that button is clicked. For example, to have a counter that you can increment, decrement, or reset to zero: ```js -viewof counter = Inputs.button([ - ["Increment", value => value + 1], - ["Decrement", value => value - 1], +Inputs.button([ + ["Increment", (value) => value + 1], + ["Decrement", (value) => value - 1], ["Reset", () => 0] ], {label: "Counter", value: 0}) ``` @@ -86,18 +108,18 @@ The available *options* are: #### Inputs.checkbox(*data*, *options*) -[A multi-choice Checkbox input of flavors](https://observablehq.com/@observablehq/input-checkbox) +[A multi-choice Checkbox input of flavors](https://observablehq.com/framework/inputs/checkbox) ```js -viewof flavor = Inputs.checkbox(["Salty", "Spicy", "Sour", "Umami"], {label: "Flavor"}) +Inputs.checkbox(["Salty", "Spicy", "Sour", "Umami"], {label: "Flavor"}) ``` -[Source](./src/checkbox.js) · [Examples](https://observablehq.com/@observablehq/input-checkbox) · A Checkbox allows the user to choose any of a given set of values (any of the given elements in the iterable *data*). Unlike a [Select](#select), a Checkbox’s choices are all visible up-front. The Checkbox’s value is an array of the elements from *data* that are currently selected. +[Source](./src/checkbox.js) · [Examples](https://observablehq.com/framework/inputs/checkbox) · [Notebook](https://observablehq.com/@observablehq/input-checkbox) · A Checkbox allows the user to choose any of a given set of values (any of the given elements in the iterable *data*). Unlike a [Select](#select), a Checkbox’s choices are all visible up-front. The Checkbox’s value is an array of the elements from *data* that are currently selected. The elements in *data* need not be strings; they can be anything. To customize display, optional *keyof* and *valueof* functions may be given; the result of the *keyof* function for each element in *data* is displayed to the user, while the result of the *valueof* function is exposed in the Checkbox’s value when selected. If *data* is a Map, the *keyof* function defaults to the map entry’s key (`([key]) => key`) and the *valueof* function defaults to the map entry’s value (`([, value]) => value`); otherwise, both *keyof* and *valueof* default to the identity function (`d => d`). For example, with [d3.group](https://github.com/d3/d3-array/blob/master/README.md#group): ```js -viewof sportAthletes = Inputs.checkbox(d3.group(athletes, d => d.sport)) +Inputs.checkbox(d3.group(athletes, (d) => d.sport)) ``` Keys may be sorted and uniqued via the *sort* and *unique* options, respectively. Elements in *data* are formatted via an optional *format* function which has the same defaults as *keyof*. As with the *label* option, the *format* function may return either a string or an HTML element. @@ -119,10 +141,10 @@ The available *options* are: #### Inputs.toggle(*options*) ```js -viewof mute = Inputs.toggle({label: "Mute"}) +Inputs.toggle({label: "Mute"}) ``` -[Source](./src/checkbox.js) · [Examples](https://observablehq.com/@observablehq/input-toggle) · A Toggle is a solitary checkbox. By default, the Toggle’s value is whether the checkbox is checked (true or false); a *values* = [*on*, *off*] option can be specified to toggle between two arbitrary values. +[Source](./src/checkbox.js) · [Examples](https://observablehq.com/framework/inputs/toggle) · [Notebook](https://observablehq.com/@observablehq/input-toggle) · A Toggle is a solitary checkbox. By default, the Toggle’s value is whether the checkbox is checked (true or false); a *values* = [*on*, *off*] option can be specified to toggle between two arbitrary values. The available *options* are: @@ -135,18 +157,18 @@ The available *options* are: #### Inputs.radio(*data*, *options*) -[A single-choice Radio input of colors](https://observablehq.com/@observablehq/input-radio) +[A single-choice Radio input of colors](https://observablehq.com/framework/inputs/radio) ```js -viewof color = Inputs.radio(["red", "green", "blue"], {label: "Color"}) +Inputs.radio(["red", "green", "blue"], {label: "Color"}) ``` -[Source](./src/checkbox.js) · [Examples](https://observablehq.com/@observablehq/input-radio) · A Radio allows the user to choose one of a given set of values. Unlike a [Select](#select), a Radio’s choices are all visible up-front. The Radio’s value is an element from *data*, or null if no choice has been made. +[Source](./src/checkbox.js) · [Examples](https://observablehq.com/framework/inputs/radio) · [Notebook](https://observablehq.com/@observablehq/input-radio) · A Radio allows the user to choose one of a given set of values. Unlike a [Select](#select), a Radio’s choices are all visible up-front. The Radio’s value is an element from *data*, or null if no choice has been made. -The elements in *data* need not be strings; they can be anything. To customize display, optional *keyof* and *valueof* functions may be given; the result of the *keyof* function for each element in *data* is displayed to the user, while the result of the *valueof* function is exposed as the Radio’s value when selected. If *data* is a Map, the *keyof* function defaults to the map entry’s key (`([key]) => key`) and the *valueof* function defaults to the map entry’s value (`([, value]) => value`); otherwise, both *keyof* and *valueof* default to the identity function (`d => d`). For example, with [d3.group](https://github.com/d3/d3-array/blob/master/README.md#group): +The elements in *data* need not be strings; they can be anything. To customize display, optional *keyof* and *valueof* functions may be given; the result of the *keyof* function for each element in *data* is displayed to the user, while the result of the *valueof* function is exposed as the Radio’s value when selected. If *data* is a Map, the *keyof* function defaults to the map entry’s key (`([key]) => key`) and the *valueof* function defaults to the map entry’s value (`([, value]) => value`); otherwise, both *keyof* and *valueof* default to the identity function (`(d) => d`). For example, with [d3.group](https://github.com/d3/d3-array/blob/master/README.md#group): ```js -viewof sportAthletes = Inputs.radio(d3.group(athletes, d => d.sport)) +Inputs.radio(d3.group(athletes, (d) => d.sport)) ``` Keys may be sorted and uniqued via the *sort* and *unique* options, respectively. Elements in *data* are formatted via an optional *format* function which has the same defaults as *keyof*. As with the *label* option, the *format* function may return either a string or an HTML element. @@ -167,13 +189,13 @@ The available *options* are: #### Inputs.range(*extent*, *options*) -[A Range input of intensity, a number between 0 and 100](https://observablehq.com/@observablehq/input-range) +[A Range input of intensity, a number between 0 and 100](https://observablehq.com/framework/inputs/range) ```js -viewof intensity = Inputs.range([0, 100], {step: 1, label: "Intensity"}) +Inputs.range([0, 100], {step: 1, label: "Intensity"}) ``` -[Source](./src/range.js) · [Examples](https://observablehq.com/@observablehq/input-range) · A Range input specifies a number between the given *extent* = [*min*, *max*] (inclusive). If an *extent* is not specified, it defaults to [0, 1]. The chosen number can be adjusted roughly with a slider, or precisely by typing a number. +[Source](./src/range.js) · [Examples](https://observablehq.com/framework/inputs/range) · [Notebook](https://observablehq.com/@observablehq/input-range) · A Range input specifies a number between the given *extent* = [*min*, *max*] (inclusive). If an *extent* is not specified, it defaults to [0, 1]. The chosen number can be adjusted roughly with a slider, or precisely by typing a number. The available *options* are: @@ -194,7 +216,7 @@ If *validate* is not defined, [*number*.checkValidity](https://html.spec.whatwg. The *format* function should return a string value that is compatible with native number parsing. Hence, the default [formatTrim](#formatTrim) is recommended. -If a *transform* function is specified, an inverse transform function *invert* is strongly recommended. If *invert* is not provided, the Range will fallback to Newton’s method, but this may be slow or inaccurate. Passing Math.sqrt, Math.log, or Math.exp as a *transform* will automatically supply the corresponding *invert*. If *min* is greater than *max*, *i.e.* if the extent is inverted, then *transform* and *invert* will default to `value => -value`. +If a *transform* function is specified, an inverse transform function *invert* is strongly recommended. If *invert* is not provided, the Range will fallback to Newton’s method, but this may be slow or inaccurate. Passing Math.sqrt, Math.log, or Math.exp as a *transform* will automatically supply the corresponding *invert*. If *min* is greater than *max*, *i.e.* if the extent is inverted, then *transform* and *invert* will default to `(value) => -value`. #### Inputs.number([*extent*, ]*options*) @@ -204,24 +226,24 @@ Equivalent to Inputs.range, except the range input is suppressed; only a number #### Inputs.search(*data*, *options*) -[A Search input over a tabular dataset of athletes](https://observablehq.com/@observablehq/input-search) +[A Search input over a tabular dataset of athletes](https://observablehq.com/framework/inputs/search) ```js -viewof foundAthletes = Inputs.search(athletes, {label: "Athletes"}) +Inputs.search(athletes, {label: "Athletes"}) ``` -[Source](./src/search.js) · [Examples](https://observablehq.com/@observablehq/input-search) · A Search input allows freeform, full-text search of an in-memory tabular dataset or an iterable (column) of values using a simple query parser. It is often used in conjunction with a [Table](#table). The value of a Search is an array of elements from the iterable *data* that match the current query. If the query is currently empty, the search input’s value is all elements in *data*. +[Source](./src/search.js) · [Examples](https://observablehq.com/framework/inputs/search) · [Notebook](https://observablehq.com/@observablehq/input-search) · A Search input allows freeform, full-text search of an in-memory tabular dataset or an iterable (column) of values using a simple query parser. It is often used in conjunction with a [Table](#table). The value of a Search is an array of elements from the iterable *data* that match the current query. If the query is currently empty, the search input’s value is all elements in *data*. A Search input can work with either tabular data (an array of objects) or a single column (an array of strings). When searching tabular input, all properties on each object in *data* are searched by default, but you can limit the search to a specific set of properties using the *column* option. For example, to only search the “sport” and “nationality” column: ```js -viewof foundAthletes = Inputs.search(athletes, {label: "Athletes", columns: ["sport", "nationality"]}) +Inputs.search(athletes, {label: "Athletes", columns: ["sport", "nationality"]}) ``` For example, to search U.S. state names: ```js -viewof state = Inputs.search(["Alabama", "Alaska", "Arizona", "Arkansas", "California", …], {label: "State"}) +Inputs.search(["Alabama", "Alaska", "Arizona", "Arkansas", "California", …], {label: "State"}) ``` The available *options* are: @@ -247,21 +269,21 @@ If a *filter* function is specified, it is invoked whenever the query changes; t #### Inputs.select(*data*, *options*) -[A Select input asking to choose a t-shirt size](https://observablehq.com/@observablehq/input-select) +[A Select input asking to choose a t-shirt size](https://observablehq.com/framework/inputs/select) ```js -viewof size = Inputs.select(["Small", "Medium", "Large"], {label: "Size"}) +Inputs.select(["Small", "Medium", "Large"], {label: "Size"}) ``` ```js -viewof inks = Inputs.select(["cyan", "magenta", "yellow", "black"], {multiple: true, label: "Inks"}) +Inputs.select(["cyan", "magenta", "yellow", "black"], {multiple: true, label: "Inks"}) ``` -[Source](./src/select.js) · [Examples](https://observablehq.com/@observablehq/input-select) · A Select allows the user to choose one of a given set of values (one of the given elements in the iterable *data*); or, if desired, multiple values may be chosen. Unlike a [Radio](#radio), only one (or a few) choices are visible up-front, affording a compact display even when many options are available. If multiple choice is allowed via the *multiple* option, the Select’s value is an array of the elements from *data* that are currently selected; if single choice is required, the Select’s value is an element from *data*, or null if no choice has been made. +[Source](./src/select.js) · [Examples](https://observablehq.com/framework/inputs/select) · [Notebook](https://observablehq.com/@observablehq/input-select) · A Select allows the user to choose one of a given set of values (one of the given elements in the iterable *data*); or, if desired, multiple values may be chosen. Unlike a [Radio](#radio), only one (or a few) choices are visible up-front, affording a compact display even when many options are available. If multiple choice is allowed via the *multiple* option, the Select’s value is an array of the elements from *data* that are currently selected; if single choice is required, the Select’s value is an element from *data*, or null if no choice has been made. -The elements in *data* need not be strings; they can be anything. To customize display, optional *keyof* and *valueof* functions may be given; the result of the *keyof* function for each element in *data* is displayed to the user, while the result of the *valueof* function is exposed as the Select’s value when selected. If *data* is a Map, the *keyof* function defaults to the map entry’s key (`([key]) => key`) and the *valueof* function defaults to the map entry’s value (`([, value]) => value`); otherwise, both *keyof* and *valueof* default to the identity function (`d => d`). For example, with [d3.group](https://github.com/d3/d3-array/blob/master/README.md#group): +The elements in *data* need not be strings; they can be anything. To customize display, optional *keyof* and *valueof* functions may be given; the result of the *keyof* function for each element in *data* is displayed to the user, while the result of the *valueof* function is exposed as the Select’s value when selected. If *data* is a Map, the *keyof* function defaults to the map entry’s key (`([key]) => key`) and the *valueof* function defaults to the map entry’s value (`([, value]) => value`); otherwise, both *keyof* and *valueof* default to the identity function (`(d) => d`). For example, with [d3.group](https://github.com/d3/d3-array/blob/master/README.md#group): ```js -viewof sportAthletes = Inputs.select(d3.group(athletes, d => d.sport)) +Inputs.select(d3.group(athletes, (d) => d.sport)) ``` Keys may be sorted and uniqued via the *sort* and *unique* options, respectively. Elements in *data* are formatted via an optional *format* function which has the same defaults as *keyof*. While the *label* option may be either a string or an HTML element, the *format* function must return a string (unlike a Radio). @@ -285,9 +307,9 @@ The available *options* are: #### Inputs.table(*data*, *options*) -[A Table input showing rows of Olympic athletes](https://observablehq.com/@observablehq/input-table) +[A Table input showing rows of Olympic athletes](https://observablehq.com/framework/inputs/table) -[Source](./src/table.js) · [Examples](https://observablehq.com/@observablehq/input-table) · A Table displays a tabular dataset; *data* should be an iterable of objects, such as the result of loading a CSV file. The *data* may also be a promise to the same, in which case the contents of the table will be lazily populated once the promise resolves. Each object corresponds to a row, while each field corresponds to a column. To improve performance with large datasets, the rows of the table are lazily rendered on scroll. Rows may be sorted by clicking column headers (once for ascending, then again for descending). +[Source](./src/table.js) · [Examples](https://observablehq.com/framework/inputs/table) · [Notebook](https://observablehq.com/@observablehq/input-table) · A Table displays a tabular dataset; *data* should be an iterable of objects, such as the result of loading a CSV file. The *data* may also be a promise to the same, in which case the contents of the table will be lazily populated once the promise resolves. Each object corresponds to a row, while each field corresponds to a column. To improve performance with large datasets, the rows of the table are lazily rendered on scroll. Rows may be sorted by clicking column headers (once for ascending, then again for descending). While intended primarily for display, a Table also serves as an input. The value of the Table is its selected rows: a filtered (and possibly sorted) view of the *data*. If the *data* is specified as a promise, while the promise is unresolved, the table’s value is undefined and attempting to set the value of the table will throw an error. Rows can be selected by clicking or shift-clicking checkboxes. See also [Search](#search), which can be used for rapid filtering of the table’s rows. @@ -311,6 +333,7 @@ The available *options* are: * *maxHeight* - the maximum table height, if any; defaults to (*rows* + 1) * 22 - 1. * *layout* - the [table layout](https://developer.mozilla.org/en-US/docs/Web/CSS/table-layout); defaults to fixed for ≤12 columns. * *required* - if true, the table’s value is all *data* if no selection; defaults to true. +* *select* - if true, allows the user to modify the table’s value by selecting rows; defaults to true. * *multiple* - if true, allow multiple rows to be selected; defaults to true. If *width* is “auto”, the table width will be based on the table contents; note that this may cause the table to resize as rows are lazily rendered. @@ -319,13 +342,13 @@ If *width* is “auto”, the table width will be based on the table contents; n #### Inputs.text(*options*) -[A Text input asking to enter your name](https://observablehq.com/@observablehq/input-text) +[A Text input asking to enter your name](https://observablehq.com/framework/inputs/text) ```js -viewof name = Inputs.text({label: "Name", placeholder: "Enter your name"}) +Inputs.text({label: "Name", placeholder: "Enter your name"}) ``` -[Source](./src/text.js) · [Examples](https://observablehq.com/@observablehq/input-text) · A Text allows freeform single-line text input. For example, a Text might be used to allow the user to enter a search query. (See also [Search](#search).) By default, a Text will report its value immediately on input. If more deliberate behavior is desired, say if the input will trigger an expensive computation or remote API, the *submit* option can be set to true to wait until a button is clicked or the Enter key is pressed. +[Source](./src/text.js) · [Examples](https://observablehq.com/framework/inputs/text) · [Notebook](https://observablehq.com/@observablehq/input-text) · A Text allows freeform single-line text input. For example, a Text might be used to allow the user to enter a search query. (See also [Search](#search).) By default, a Text will report its value immediately on input. If more deliberate behavior is desired, say if the input will trigger an expensive computation or remote API, the *submit* option can be set to true to wait until a button is clicked or the Enter key is pressed. The available *options* are: @@ -375,13 +398,13 @@ Like Inputs.text, but where *type* is color. The color value is represented as a #### Inputs.textarea(*options*) -[A Textarea asking for your biography](https://observablehq.com/@observablehq/input-textarea) +[A Textarea asking for your biography](https://observablehq.com/framework/inputs/textarea) ```js -viewof bio = Inputs.textarea({label: "Biography", placeholder: "Tell us a little about yourself…"}) +Inputs.textarea({label: "Biography", placeholder: "Tell us a little about yourself…"}) ``` -[Source](./src/textarea.js) · [Examples](https://observablehq.com/@observablehq/input-textarea) · A Textarea allows multi-line freeform text input. By default, a Textarea will report its value immediately on input. If more deliberate behavior is desired, the *submit* option can be set to true to wait until a button is clicked or the appropriate keyboard shortcut (such as Command-Enter on macOS) is pressed. +[Source](./src/textarea.js) · [Examples](https://observablehq.com/framework/inputs/textarea) · [Notebook](https://observablehq.com/@observablehq/input-textarea) · A Textarea allows multi-line freeform text input. By default, a Textarea will report its value immediately on input. If more deliberate behavior is desired, the *submit* option can be set to true to wait until a button is clicked or the appropriate keyboard shortcut (such as Command-Enter on macOS) is pressed. The available *options* are: @@ -410,10 +433,10 @@ If *validate* is not defined, [*text*.checkValidity](https://html.spec.whatwg.or #### Inputs.date(*options*) ```js -viewof start = Inputs.date({label: "Start date", value: "1982-03-06"}) +Inputs.date({label: "Start date", value: "1982-03-06"}) ``` -[Source](./src/date.js) · [Examples](https://observablehq.com/@observablehq/input-date) · A Date allows a [calendar-based input](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/date). By default, a Date will report its value immediately on input. If more deliberate behavior is desired, say if the input will trigger an expensive computation or remote API, the *submit* option can be set to true to wait until a button is clicked or the Enter key is pressed. +[Source](./src/date.js) · [Examples](https://observablehq.com/framework/inputs/date) · [Notebook](https://observablehq.com/@observablehq/input-date) · A Date allows a [calendar-based input](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/date). By default, a Date will report its value immediately on input. If more deliberate behavior is desired, say if the input will trigger an expensive computation or remote API, the *submit* option can be set to true to wait until a button is clicked or the Enter key is pressed. The available *options* are: @@ -433,7 +456,7 @@ The value of the input is a Date instance at UTC midnight of the specified date, #### Inputs.datetime(*options*) ```js -viewof start = Inputs.datetime({label: "Start date", value: "1982-03-06T02:30"}) +Inputs.datetime({label: "Start date", value: "1982-03-06T02:30"}) ``` Like Inputs.date, but allows a time to also be specified in the user’s local time zone. @@ -443,10 +466,7 @@ Like Inputs.date, but allows a time to also be specified in the user’s local t #### Inputs.file(*options*) ```js -viewof recordsFile = Inputs.file({label: "Records", accept: ".json"}) -``` -```js -records = recordsFile.json() +Inputs.file({label: "Records", accept: ".json"}) ``` [Source](./src/file.js) · A file input allows the user to pick one or more local files. These files will be exposed as objects with the same API as [Observable file attachments](https://github.com/observablehq/stdlib/blob/main/README.md#file-attachments). @@ -470,10 +490,10 @@ Note that the value of file input cannot be set programmatically; it can only be #### Inputs.form(*inputs*, *options*) -[Source](./src/form.js) · [Examples](https://observablehq.com/@observablehq/input-form) · Returns a compound input for the specified array or object of *inputs*. This allows multiple inputs to be combined into a single cell for a more compact display. For example, to define an input for the value `rgb` that is a three-element array [*r*, *g*, *b*] of numbers: +[Source](./src/form.js) · [Examples](https://observablehq.com/framework/inputs/form) · [Notebook](https://observablehq.com/@observablehq/input-form) · Returns a compound input for the specified array or object of *inputs*. This allows multiple inputs to be combined into a single cell for a more compact display. For example, to define an input for the value `rgb` that is a three-element array [*r*, *g*, *b*] of numbers: ```js -viewof rgb = Inputs.form([ +Inputs.form([ Inputs.range([0, 255], {step: 1, label: "r"}), Inputs.range([0, 255], {step: 1, label: "g"}), Inputs.range([0, 255], {step: 1, label: "b"}) @@ -483,7 +503,7 @@ viewof rgb = Inputs.form([ Alternatively, to represent `rgb` as an object {*r*, *g*, *b*}: ```js -viewof rgb = Inputs.form({ +Inputs.form({ r: Inputs.range([0, 255], {step: 1, label: "r"}), g: Inputs.range([0, 255], {step: 1, label: "g"}), b: Inputs.range([0, 255], {step: 1, label: "b"}) @@ -498,20 +518,13 @@ If the *template* object is not specified, the given inputs are wrapped in a DIV #### Inputs.input(*value*) -[Source](./src/input.js) · [Examples](https://observablehq.com/@observablehq/synchronized-inputs) · Returns an [EventTarget](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget) with the specified *value*. This is typically used in conjunction with [bind](#inputsbindtarget-source-invalidation) to synchronize multiple inputs, with the Input being the primary state store. An Input is similar to a [mutable](https://observablehq.com/@observablehq/introduction-to-mutable-state), except that it allows listeners. +[Source](./src/input.js) · [Notebook](https://observablehq.com/@observablehq/synchronized-inputs) · Returns an [EventTarget](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget) with the specified *value*. This is typically used in conjunction with [bind](#inputsbindtarget-source-invalidation) to synchronize multiple inputs, with the Input being the primary state store. An Input is similar to a [mutable](https://observablehq.com/@observablehq/introduction-to-mutable-state), except that it allows listeners. #### Inputs.bind(*target*, *source*, *invalidation*) -[Source](./src/bind.js) · [Examples](https://observablehq.com/@observablehq/synchronized-inputs) · The bind function allows a *target* input to be bound to a *source* input, synchronizing the two: interaction with the *source* input will propagate to the *target* input and *vice versa*. +[Source](./src/bind.js) · [Notebook](https://observablehq.com/@observablehq/synchronized-inputs) · The bind function allows a *target* input to be bound to a *source* input, synchronizing the two: interaction with the *source* input will propagate to the *target* input and *vice versa*. -The relationship between *target* and *source* is not symmetric: the *target* input should be considered a dependant of the *source* input, and if desired, only the *source* should be declared an Observable view. For example: - -```js -viewof i = Inputs.input(42) // the “primary” input -``` -```js -Inputs.bind(Inputs.range([0, 100]), viewof i) // a bound “secondary” input -``` +The relationship between *target* and *source* is not symmetric: the *target* input should be considered a dependant of the *source* input, and if desired, only the *source* should be declared an Observable view. When the *target* emits a type-appropriate event, the *target*’s type-appropriate value will be applied to the *source* and a type-appropriate event will be dispatched on the *source*; when the *source* emits a type-appropriate event, the *source*’s type-appropriate value will be applied to the *target*, but *no event will be dispatched*, avoiding an infinite loop. diff --git a/bin/clean-css.js b/bin/clean-css.js new file mode 100644 index 0000000..1befc60 --- /dev/null +++ b/bin/clean-css.js @@ -0,0 +1,15 @@ +import {stdin} from "node:process"; +import CleanCSS from "clean-css"; + +stdin.setEncoding("utf-8"); + +let input = ""; + +for await (const chunk of stdin) { + input += chunk; +} + +// A unique namespace for our styles. +const styleNs = "inputs-3a86ea"; + +process.stdout.write(new CleanCSS().minify(input.replace(/\.__ns__\b/g, `.${styleNs}`)).styles); diff --git a/package.json b/package.json index 296eb9d..f04ea86 100644 --- a/package.json +++ b/package.json @@ -1,26 +1,30 @@ { "name": "@observablehq/inputs", - "description": "User interface components for Observable notebooks", - "version": "0.10.6", + "description": "Lightweight user interface components", + "version": "0.11.0", "author": { "name": "Observable, Inc.", "url": "https://observablehq.com" }, "license": "ISC", "type": "module", - "main": "src/index.js", - "module": "src/index.js", + "main": "dist/index.js", + "module": "dist/index.js", "jsdelivr": "dist/inputs.min.js", "unpkg": "dist/inputs.min.js", "exports": { - "umd": "./dist/inputs.min.js", - "default": "./src/index.js" + ".": { + "umd": "./dist/inputs.min.js", + "default": "./dist/index.js" + }, + "./dist/index.css": "./dist/index.css" }, "repository": { "type": "git", "url": "https://github.com/observablehq/inputs.git" }, "files": [ + "dist/**/*.css", "dist/**/*.js", "src/**/*.js" ], @@ -29,34 +33,33 @@ }, "scripts": { "test": "yarn test:mocha && yarn test:lint", - "test:mocha": "mkdir -p test/output && mocha -r module-alias/register 'test/**/*-test.js' test/input.js", + "test:mocha": "mkdir -p test/output && mocha 'test/**/*-test.js' test/input.js", "test:lint": "eslint src test", - "prepublishOnly": "rm -rf dist && rollup -c", + "prepublishOnly": "rm -rf dist && rollup -c && node bin/clean-css < src/style.css > dist/index.css", "postpublish": "git push && git push --tags", - "dev": "snowpack dev" + "dev": "vite dev" }, "_moduleAliases": { "@observablehq/inputs": "./src/index.js" }, "devDependencies": { - "@rollup/plugin-json": "4", - "@rollup/plugin-node-resolve": "13", - "@rollup/plugin-replace": "3", - "apache-arrow": "17", - "clean-css": "5", - "d3": "7", - "eslint": "8", - "js-beautify": "1", - "jsdom": "19", - "jsesc": "3", - "mocha": "9", - "module-alias": "2", - "rollup": "2", - "rollup-plugin-terser": "7", - "snowpack": "3" + "@rollup/plugin-json": "^4.1.0", + "@rollup/plugin-node-resolve": "^13.3.0", + "@rollup/plugin-replace": "^3.1.0", + "apache-arrow": "^17.0.0", + "clean-css": "^5.3.3", + "d3": "^7.9.0", + "eslint": "^8.57.0", + "js-beautify": "^1.15.1", + "jsdom": "^24.1.1", + "jsesc": "^3.0.2", + "mocha": "^10.7.0", + "rollup": "^2.79.1", + "rollup-plugin-terser": "^7.0.2", + "vite": "^5.3.5" }, "dependencies": { - "htl": "0.3", + "htl": "^0.3.1", "isoformat": "^0.2.0" }, "publishConfig": { diff --git a/rollup.config.js b/rollup.config.js index c39e8d1..208621d 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,4 +1,3 @@ -import crypto from "crypto"; import fs from "fs"; import path from "path"; import {terser} from "rollup-plugin-terser"; @@ -22,10 +21,10 @@ const copyrights = fs.readFileSync("./LICENSE", "utf-8") .filter(line => /^copyright\s+/i.test(line)) .map(line => line.replace(/^copyright\s+/i, "")); -// Create a content-hashed namespace for our styles. +// A unique namespace for our styles. +const styleNs = "inputs-3a86ea"; + const stylePath = path.resolve("./src/style.css"); -const styleHash = crypto.createHash("sha256").update(fs.readFileSync(stylePath, "utf8")).digest("hex").slice(0, 6); -const styleNs = `oi-${styleHash}`; // A lil’ Rollup plugin to allow importing of style.css. const css = { @@ -86,5 +85,19 @@ export default [ ...config.plugins, terser({output: {preamble: config.output.banner}}) ] + }, + { + input: "src/index.js", + external: ["htl", "isoformat"], + output: { + indent: false, + banner: `// ${meta.name} v${meta.version} Copyright ${copyrights.join(", ")}`, + format: "es", + file: "dist/index.js" + }, + plugins: [ + node(), + replace({__ns__: styleNs, preventAssignment: true}) + ] } ]; diff --git a/snowpack.config.js b/snowpack.config.js deleted file mode 100644 index bb05e82..0000000 --- a/snowpack.config.js +++ /dev/null @@ -1,13 +0,0 @@ -export default { - alias: { - "@observablehq/inputs": "./src/index.js" - }, - devOptions: { - port: 8008 - }, - mount: { - "src": "/src", - "test/data": "/data", - "scratch": "/" - } -}; diff --git a/src/table.js b/src/table.js index 1f708d8..ad1efd4 100644 --- a/src/table.js +++ b/src/table.js @@ -54,6 +54,7 @@ function initialize( rows = 11.5, // maximum number of rows to show width = {}, // object of column name to width, or overall table width multiple = true, + select: selectable = true, // is the table selectable? layout // "fixed" or "auto" } = {} ) { @@ -96,8 +97,8 @@ function initialize( let anchor = null, head = null; const tbody = html``; - const tr = html`${columns.map(() => html``)}`; - const theadr = html`${columns.map((column) => html` resort(event, column)}>${header && column in header ? header[column] : column}`)}`; + const tr = html`${selectable ? html`` : null}${columns.map(() => html``)}`; + const theadr = html`${selectable ? html`` : null}${columns.map((column) => html` resort(event, column)}>${header && column in header ? header[column] : column}`)}`; root.appendChild(html.fragment`${minlengthof(1) || columns.length ? theadr : null} ${tbody} @@ -125,9 +126,11 @@ function initialize( function appendRow(d, i) { const itr = tr.cloneNode(true); const input = inputof(itr); - input.onclick = reselect; - input.checked = selected.has(i); - input.value = i; + if (input != null) { + input.onclick = reselect; + input.checked = selected.has(i); + input.value = i; + } if (d != null) for (let j = 0; j < columns.length; ++j) { let column = columns[j]; let value = d[column]; @@ -243,6 +246,7 @@ function initialize( function reinput() { const check = inputof(theadr); + if (check == null) return; check.disabled = !multiple && !selected.size; check.indeterminate = multiple && selected.size && selected.size !== N; // assume materalized! check.checked = selected.size; diff --git a/test/arrow-test.js b/test/arrow-test.js index 7667c64..b909041 100644 --- a/test/arrow-test.js +++ b/test/arrow-test.js @@ -1,7 +1,7 @@ -import assert from "assert"; -import {table} from "@observablehq/inputs"; +import assert from "node:assert"; import {tableFromJSON} from "apache-arrow"; import {autoType, csv} from "d3"; +import {table} from "../src/index.js"; import it from "./jsdom.js"; it("Inputs.table() detects dates in Arrow tables", async () => { diff --git a/test/bind-test.js b/test/bind-test.js index c5ed91c..fec2df3 100644 --- a/test/bind-test.js +++ b/test/bind-test.js @@ -1,5 +1,5 @@ -import assert from "assert"; -import * as Inputs from "@observablehq/inputs"; +import assert from "node:assert"; +import * as Inputs from "../src/index.js"; import it from "./jsdom.js"; it("Inputs.bind(button, button) dispatches click events", () => { diff --git a/test/checkbox-test.js b/test/checkbox-test.js index 45ec18b..8ba31df 100644 --- a/test/checkbox-test.js +++ b/test/checkbox-test.js @@ -1,5 +1,5 @@ -import * as Inputs from "@observablehq/inputs"; -import assert from "assert"; +import assert from "node:assert"; +import * as Inputs from "../src/index.js"; import it from "./jsdom.js"; it("Inputs.checkbox([]) handles empty options", () => { diff --git a/test/date-test.js b/test/date-test.js index b8bc1a5..6e42f8e 100644 --- a/test/date-test.js +++ b/test/date-test.js @@ -1,5 +1,5 @@ -import * as Inputs from "@observablehq/inputs"; -import assert from "assert"; +import assert from "node:assert"; +import * as Inputs from "../src/index.js"; import it from "./jsdom.js"; it("Inputs.date() sets the initial value to null", () => { diff --git a/test/input.js b/test/input.js index 3aedfa0..15f1dff 100644 --- a/test/input.js +++ b/test/input.js @@ -1,6 +1,6 @@ -import assert from "assert"; -import {promises as fs} from "fs"; -import * as path from "path"; +import assert from "node:assert"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; import beautify from "js-beautify"; import it from "./jsdom.js"; import * as inputs from "./inputs/index.js"; diff --git a/test/inputs/buttons.js b/test/inputs/buttons.js index 9910276..e1c6097 100644 --- a/test/inputs/buttons.js +++ b/test/inputs/buttons.js @@ -1,5 +1,5 @@ -import * as Inputs from "@observablehq/inputs"; import {html} from "htl"; +import * as Inputs from "../../src/index.js"; export async function button() { return Inputs.button(); diff --git a/test/inputs/checkboxes.js b/test/inputs/checkboxes.js index 91557de..163302f 100644 --- a/test/inputs/checkboxes.js +++ b/test/inputs/checkboxes.js @@ -1,5 +1,5 @@ -import * as Inputs from "@observablehq/inputs"; import {html} from "htl"; +import * as Inputs from "../../src/index.js"; export async function checkbox() { return Inputs.checkbox(["red", "green", "blue"]); diff --git a/test/inputs/colors.js b/test/inputs/colors.js index 3fa4ce4..8162990 100644 --- a/test/inputs/colors.js +++ b/test/inputs/colors.js @@ -1,5 +1,5 @@ -import * as Inputs from "@observablehq/inputs"; import {html} from "htl"; +import * as Inputs from "../../src/index.js"; export async function color() { return Inputs.color(); diff --git a/test/inputs/dates.js b/test/inputs/dates.js index 0a95be9..e9b36be 100644 --- a/test/inputs/dates.js +++ b/test/inputs/dates.js @@ -1,4 +1,4 @@ -import * as Inputs from "@observablehq/inputs"; +import * as Inputs from "../../src/index.js"; export async function date() { return Inputs.date(); diff --git a/test/inputs/files.js b/test/inputs/files.js index b5e8828..6f0771f 100644 --- a/test/inputs/files.js +++ b/test/inputs/files.js @@ -1,4 +1,4 @@ -import * as Inputs from "@observablehq/inputs"; +import * as Inputs from "../../src/index.js"; const Inputs_file = Inputs.fileOf(class AbstractFile {}); diff --git a/test/inputs/forms.js b/test/inputs/forms.js index 837dbcc..1e956b3 100644 --- a/test/inputs/forms.js +++ b/test/inputs/forms.js @@ -1,4 +1,4 @@ -import * as Inputs from "@observablehq/inputs"; +import * as Inputs from "../../src/index.js"; export async function formArray() { return Inputs.form([ diff --git a/test/inputs/numbers.js b/test/inputs/numbers.js index faca9a9..1c6a0c6 100644 --- a/test/inputs/numbers.js +++ b/test/inputs/numbers.js @@ -1,5 +1,5 @@ -import * as Inputs from "@observablehq/inputs"; import {html} from "htl"; +import * as Inputs from "../../src/index.js"; export async function number() { return Inputs.number(); diff --git a/test/inputs/radios.js b/test/inputs/radios.js index a1f8bd8..ecbe927 100644 --- a/test/inputs/radios.js +++ b/test/inputs/radios.js @@ -1,5 +1,5 @@ -import * as Inputs from "@observablehq/inputs"; import {html} from "htl"; +import * as Inputs from "../../src/index.js"; export async function radio() { return Inputs.radio(["red", "green", "blue"]); diff --git a/test/inputs/ranges.js b/test/inputs/ranges.js index 7d285dd..bb3591a 100644 --- a/test/inputs/ranges.js +++ b/test/inputs/ranges.js @@ -1,5 +1,5 @@ -import * as Inputs from "@observablehq/inputs"; import {html} from "htl"; +import * as Inputs from "../../src/index.js"; export async function range() { return Inputs.range(); diff --git a/test/inputs/searches.js b/test/inputs/searches.js index f6962ea..9ece19d 100644 --- a/test/inputs/searches.js +++ b/test/inputs/searches.js @@ -1,5 +1,5 @@ -import * as Inputs from "@observablehq/inputs"; import {html} from "htl"; +import * as Inputs from "../../src/index.js"; export async function search() { return Inputs.search(["red", "green", "blue"]); diff --git a/test/inputs/selects.js b/test/inputs/selects.js index b4d778d..687d3a5 100644 --- a/test/inputs/selects.js +++ b/test/inputs/selects.js @@ -1,5 +1,5 @@ -import * as Inputs from "@observablehq/inputs"; import {html} from "htl"; +import * as Inputs from "../../src/index.js"; export async function select() { return Inputs.select(["red", "green", "blue"]); diff --git a/test/inputs/tables.js b/test/inputs/tables.js index 5e8b27a..55d27a9 100644 --- a/test/inputs/tables.js +++ b/test/inputs/tables.js @@ -1,6 +1,6 @@ -import * as Inputs from "@observablehq/inputs"; import * as d3 from "d3"; import {html} from "htl"; +import * as Inputs from "../../src/index.js"; export async function table() { const athletes = await d3.csv("data/athletes.csv"); diff --git a/test/inputs/textareas.js b/test/inputs/textareas.js index e265258..cb9c00c 100644 --- a/test/inputs/textareas.js +++ b/test/inputs/textareas.js @@ -1,5 +1,5 @@ -import * as Inputs from "@observablehq/inputs"; import {html} from "htl"; +import * as Inputs from "../../src/index.js"; export async function textarea() { return Inputs.textarea(); diff --git a/test/inputs/texts.js b/test/inputs/texts.js index e8b1937..bb8eac5 100644 --- a/test/inputs/texts.js +++ b/test/inputs/texts.js @@ -1,5 +1,5 @@ -import * as Inputs from "@observablehq/inputs"; import {html} from "htl"; +import * as Inputs from "../../src/index.js"; export async function text() { return Inputs.text(); diff --git a/test/jsdom.js b/test/jsdom.js index 28781b2..82b19a0 100644 --- a/test/jsdom.js +++ b/test/jsdom.js @@ -1,5 +1,5 @@ -import {promises as fs} from "fs"; -import * as path from "path"; +import {promises as fs} from "node:fs"; +import * as path from "node:path"; import {JSDOM} from "jsdom"; export default function jsdomit(description, run) { diff --git a/test/radio-test.js b/test/radio-test.js index b92f367..c397c95 100644 --- a/test/radio-test.js +++ b/test/radio-test.js @@ -1,5 +1,5 @@ -import * as Inputs from "@observablehq/inputs"; -import assert from "assert"; +import assert from "node:assert"; +import * as Inputs from "../src/index.js"; import it from "./jsdom.js"; it("Inputs.radio([]) handles empty options", () => { diff --git a/test/range-test.js b/test/range-test.js index ce4e346..574f3e0 100644 --- a/test/range-test.js +++ b/test/range-test.js @@ -1,7 +1,7 @@ -import * as Inputs from "@observablehq/inputs"; +import assert from "node:assert"; import {html} from "htl"; +import * as Inputs from "../src/index.js"; import {number, string} from "./coercible.js"; -import assert from "assert"; import it from "./jsdom.js"; it("Inputs.range([min, max]) sets the min and max", () => { diff --git a/test/scratch.html b/test/scratch.html index 6d88f22..1bcc4df 100644 --- a/test/scratch.html +++ b/test/scratch.html @@ -1,6 +1,7 @@ - + +