diff --git a/.changeset/color-picker-v1.md b/.changeset/color-picker-v1.md new file mode 100644 index 0000000..f9ef20b --- /dev/null +++ b/.changeset/color-picker-v1.md @@ -0,0 +1,5 @@ +--- +'@human-kit/ui': minor +--- + +Add the ColorPicker primitive with `Root`, `Label`, `Area`, `AreaThumb`, `Slider`, `SliderThumb`, `HexField`, `ChannelField`, `SwatchList`, `Swatch`, `Preview` and `EyeDropper`. The color is held as hue, saturation and brightness. The square keeps its shape at each hue, and a color that goes to black keeps the hue it had. The value is text: a hex color, `rgb()`, `hsl()` and `hsb()` come in, and `format` decides what goes out. `alpha` adds the fourth number and the `alpha` channel. `ColorPicker.AreaThumb` holds one native slider for each axis, each with the name of its channel and with `aria-valuetext`. The arrows move both axes from either of them. `ColorPicker.SwatchList` is a listbox of options. The parts paint nothing: `--color-picker-value`, `--color-picker-hue-color`, `--color-picker-area-x`, `--color-picker-slider-start` and `--color-picker-swatch-color` give your CSS what it needs. diff --git a/.changeset/pin-input-v1.md b/.changeset/pin-input-v1.md new file mode 100644 index 0000000..1921e72 --- /dev/null +++ b/.changeset/pin-input-v1.md @@ -0,0 +1,5 @@ +--- +'@human-kit/ui': minor +--- + +Add the PinInput primitive with `Root`, `Label` and `Cell`. Each cell is a real input with its own name, its `inputmode` and its `autocomplete`. A telephone shows the correct keyboard, and a code from a message fills the cells with one touch. A paste goes across the cells from the cell it starts in, and the characters that `type` or `pattern` refuses are dropped. The value has no holes. The characters fill the cells from the first one, and a press on a cell past the first empty one goes to that one. A character that goes away takes the ones after it one cell to the left. `onComplete` runs when the last cell takes a character, `mask` hides the characters, and `name` sends the whole value in a form. diff --git a/.changeset/rating-v1.md b/.changeset/rating-v1.md new file mode 100644 index 0000000..12c62fc --- /dev/null +++ b/.changeset/rating-v1.md @@ -0,0 +1,5 @@ +--- +'@human-kit/ui': minor +--- + +Add the Rating primitive with `Root`, `Label`, `Output` and `Item`. With whole items the root is a radio group, and each item is a radio with its own name, because each value is one item. With `precision={0.5}` the root is a slider with `aria-valuenow` and `aria-valuetext`, because a radio group cannot say 3.5. The keyboard is the same in the two shapes. The arrows step by the precision, `Home` and `End` go to the ends, and `Delete` clears the value. The value follows the pointer before a press, in `Rating.Output` and in `--rating-display-value`. Each item carries `--rating-item-fill`, from 0 to 1, thus one shape over the item gives you a half star. `name` sends the value in a form, and a `
` reset takes the first value back. diff --git a/docs/src/content/color-picker/api.json b/docs/src/content/color-picker/api.json new file mode 100644 index 0000000..e407c7a --- /dev/null +++ b/docs/src/content/color-picker/api.json @@ -0,0 +1,746 @@ +{ + "component": "color-picker", + "parts": [ + { + "name": "Root", + "description": "The color, and the context of each control. It renders a div with role=\"group\", named by ColorPicker.Label, and it carries the color in --color-picker-value.", + "props": [ + { + "name": "id", + "type": "string", + "required": false, + "default": null, + "description": "A stable id, from which the component makes its internal ARIA ids. Give one on a server." + }, + { + "name": "value", + "type": "string", + "required": false, + "default": null, + "description": "The color, as text: a hex color, `rgb()`, `hsl()` or `hsb()`. You can bind it with\n`bind:value`. The component answers in the format of `format`." + }, + { + "name": "defaultValue", + "type": "string", + "required": false, + "default": null, + "description": "The color at the start, for when you give no `value`. The default is `#000000`." + }, + { + "name": "controlledValue", + "type": "boolean", + "required": false, + "default": "false", + "description": "Give your own code full control of the color. The component stops to write back to `value`,\nand it reports only through `onChange`. Thus the parent can refuse a change." + }, + { + "name": "onChange", + "type": "(value: string, details: ColorPickerChangeDetails) => void", + "required": false, + "default": null, + "description": "The component calls it on each change, also on each move of a drag." + }, + { + "name": "onChangeEnd", + "type": "(value: string, details: ColorPickerChangeDetails) => void", + "required": false, + "default": null, + "description": "The component calls it when a sequence of changes ends: at the release of a key, and at the\nend of a drag. Use it for work that must not run on each move." + }, + { + "name": "format", + "type": "ColorFormat", + "required": false, + "default": "'hex'", + "description": "The text format of the value. The default is `hex`." + }, + { + "name": "alpha", + "type": "boolean", + "required": false, + "default": "false", + "description": "Keeps an alpha in the color. The value then holds a fourth number, and `ColorPicker.Slider`\naccepts the `alpha` channel. The default is off." + }, + { + "name": "disabled", + "type": "boolean", + "required": false, + "default": "false", + "description": "Disables each control of the picker. The controls leave the tab order." + }, + { + "name": "readonly", + "type": "boolean", + "required": false, + "default": "false", + "description": "Keeps the controls in the tab order, but the color does not change." + }, + { + "name": "invalid", + "type": "boolean", + "required": false, + "default": "false", + "description": "Marks the color as invalid. Each control gets `aria-invalid`." + }, + { + "name": "name", + "type": "string", + "required": false, + "default": null, + "description": "The name of the hidden input that carries the color in a form." + }, + { + "name": "form", + "type": "string", + "required": false, + "default": null, + "description": "The id of the form that the hidden input belongs to, when the picker is outside it." + }, + { + "name": "aria-label", + "type": "string", + "required": false, + "default": null, + "description": "The accessible name of the picker, for when there is no `ColorPicker.Label`." + }, + { + "name": "aria-labelledby", + "type": "string", + "required": false, + "default": null, + "description": "The id of the element that gives the picker its name, in place of `ColorPicker.Label`." + }, + { + "name": "aria-describedby", + "type": "string", + "required": false, + "default": null, + "description": "The id of the element that describes the picker, for example an error message." + }, + { + "name": "children", + "type": "Snippet", + "required": false, + "default": null, + "description": "The content: the label, the area, the sliders and the fields." + }, + { + "name": "class", + "type": "string", + "required": false, + "default": "''", + "description": "The CSS class names of the root element." + }, + { + "name": "element", + "type": "HTMLDivElement | null", + "required": false, + "default": "null", + "description": "A bindable reference to the root element." + }, + { + "name": "context", + "type": "ColorPickerContext", + "required": false, + "default": null, + "description": "A bindable reference to the context, for a composition of your own." + } + ], + "dataAttributes": [ + { + "name": "data-alpha", + "description": "Present while the picker holds an alpha." + }, + { + "name": "data-color-picker-root", + "description": "" + }, + { + "name": "data-color-picker-value", + "description": "" + }, + { + "name": "data-disabled", + "description": "Present while the picker is disabled." + }, + { + "name": "data-format", + "description": "The text format of the value: \"hex\", \"rgb\", \"hsl\" or \"hsb\"." + }, + { + "name": "data-invalid", + "description": "Present while the color is invalid." + }, + { + "name": "data-readonly", + "description": "Present while the picker is read-only." + } + ] + }, + { + "name": "Label", + "description": "The accessible name of the picker. It renders a span, and the root points at it with aria-labelledby.", + "props": [ + { + "name": "class", + "type": "string", + "required": false, + "default": "''", + "description": "The CSS class names of the label." + } + ], + "dataAttributes": [ + { + "name": "data-color-picker-label", + "description": "" + }, + { + "name": "data-disabled", + "description": "Present while the picker is disabled." + } + ] + }, + { + "name": "Area", + "description": "The square of two channels. A press in it moves the thumb there and starts a drag. It is position: relative, and it carries the position of the thumb in --color-picker-area-x and --color-picker-area-y.", + "props": [ + { + "name": "xChannel", + "type": "ColorChannel", + "required": false, + "default": "'saturation'", + "description": "The channel of the horizontal axis. The default is `saturation`." + }, + { + "name": "yChannel", + "type": "ColorChannel", + "required": false, + "default": "'brightness'", + "description": "The channel of the vertical axis, which counts from the bottom. The default is `brightness`." + }, + { + "name": "children", + "type": "Snippet", + "required": false, + "default": null, + "description": "The content: a `ColorPicker.AreaThumb`." + }, + { + "name": "class", + "type": "string", + "required": false, + "default": "''", + "description": "The CSS class names of the area." + }, + { + "name": "element", + "type": "HTMLDivElement | null", + "required": false, + "default": "null", + "description": "A bindable reference to the area element." + } + ], + "dataAttributes": [ + { + "name": "data-color-picker-area", + "description": "" + }, + { + "name": "data-disabled", + "description": "Present while the picker is disabled." + }, + { + "name": "data-dragging", + "description": "Present while a pointer moves the thumb." + }, + { + "name": "data-readonly", + "description": "Present while the picker is read-only." + }, + { + "name": "data-x-channel", + "description": "The channel of the horizontal axis." + }, + { + "name": "data-y-channel", + "description": "The channel of the vertical axis." + } + ] + }, + { + "name": "AreaThumb", + "description": "The handle of the square. It renders a div at a position in percent, with two native sliders in it: one for each axis. The inputs have the focus and the ARIA state.", + "props": [ + { + "name": "children", + "type": "Snippet<[ColorPickerAreaThumbRenderState]> | Snippet", + "required": false, + "default": null, + "description": "The content. As a snippet with one argument, it receives the render state: `xPercent`,\n`yPercent`, `dragging` and `focused`." + }, + { + "name": "class", + "type": "string", + "required": false, + "default": "''", + "description": "The CSS class names of the thumb." + }, + { + "name": "element", + "type": "HTMLDivElement | null", + "required": false, + "default": "null", + "description": "A bindable reference to the thumb element." + } + ], + "dataAttributes": [ + { + "name": "data-axis", + "description": "The axis of one native input: \"x\" or \"y\"." + }, + { + "name": "data-color-picker-area-input", + "description": "" + }, + { + "name": "data-color-picker-area-thumb", + "description": "" + }, + { + "name": "data-disabled", + "description": "Present while the picker is disabled." + }, + { + "name": "data-dragging", + "description": "Present while a pointer moves the thumb." + }, + { + "name": "data-focus-visible", + "description": "Present while the focus on the thumb must be visible (keyboard modality)." + }, + { + "name": "data-focused", + "description": "Present while one of the two inputs has the focus." + }, + { + "name": "data-readonly", + "description": "Present while the picker is read-only." + } + ] + }, + { + "name": "Slider", + "description": "The track of one channel. A press on it moves the thumb there and starts a drag. It carries the two ends of the channel in --color-picker-slider-start and --color-picker-slider-end.", + "props": [ + { + "name": "channel", + "type": "ColorChannel", + "required": true, + "default": null, + "description": "The channel the slider moves, for example `hue` or `alpha`." + }, + { + "name": "orientation", + "type": "ColorPickerSliderOrientation", + "required": false, + "default": "'horizontal'", + "description": "The direction of the track. The default is horizontal." + }, + { + "name": "children", + "type": "Snippet", + "required": false, + "default": null, + "description": "The content: a `ColorPicker.SliderThumb`." + }, + { + "name": "class", + "type": "string", + "required": false, + "default": "''", + "description": "The CSS class names of the track." + }, + { + "name": "element", + "type": "HTMLDivElement | null", + "required": false, + "default": "null", + "description": "A bindable reference to the track element." + } + ], + "dataAttributes": [ + { + "name": "data-channel", + "description": "The channel of the slider." + }, + { + "name": "data-color-picker-slider", + "description": "" + }, + { + "name": "data-disabled", + "description": "Present while the picker is disabled." + }, + { + "name": "data-dragging", + "description": "Present while a pointer moves the thumb." + }, + { + "name": "data-orientation", + "description": "The orientation: \"horizontal\" or \"vertical\"." + }, + { + "name": "data-readonly", + "description": "Present while the picker is read-only." + } + ] + }, + { + "name": "SliderThumb", + "description": "The handle of one channel. It renders a div at a position in percent, with a native slider in it. The input has the focus and the ARIA state.", + "props": [ + { + "name": "children", + "type": "Snippet<[ColorPickerSliderThumbRenderState]> | Snippet", + "required": false, + "default": null, + "description": "The content. As a snippet with one argument, it receives the render state: `percent`,\n`value`, `dragging` and `focused`." + }, + { + "name": "class", + "type": "string", + "required": false, + "default": "''", + "description": "The CSS class names of the thumb." + }, + { + "name": "element", + "type": "HTMLDivElement | null", + "required": false, + "default": "null", + "description": "A bindable reference to the thumb element." + } + ], + "dataAttributes": [ + { + "name": "data-channel", + "description": "The channel of the slider." + }, + { + "name": "data-color-picker-slider-input", + "description": "" + }, + { + "name": "data-color-picker-slider-thumb", + "description": "" + }, + { + "name": "data-disabled", + "description": "Present while the picker is disabled." + }, + { + "name": "data-dragging", + "description": "Present while a pointer moves the thumb." + }, + { + "name": "data-focus-visible", + "description": "Present while the focus on the thumb must be visible (keyboard modality)." + }, + { + "name": "data-focused", + "description": "Present while the native input has the focus." + }, + { + "name": "data-orientation", + "description": "The orientation: \"horizontal\" or \"vertical\"." + }, + { + "name": "data-readonly", + "description": "Present while the picker is read-only." + } + ] + }, + { + "name": "HexField", + "description": "The color as a hex text. It renders a native text input, and it reads the text at Enter and when the focus leaves.", + "props": [ + { + "name": "class", + "type": "string", + "required": false, + "default": "''", + "description": "The CSS class names of the field." + }, + { + "name": "element", + "type": "HTMLInputElement | null", + "required": false, + "default": "null", + "description": "A bindable reference to the input element." + } + ], + "dataAttributes": [ + { + "name": "data-color-picker-hex-field", + "description": "" + }, + { + "name": "data-disabled", + "description": "Present while the picker is disabled." + }, + { + "name": "data-focused", + "description": "Present while the field has the focus." + }, + { + "name": "data-invalid", + "description": "Present while the color is invalid." + }, + { + "name": "data-readonly", + "description": "Present while the picker is read-only." + } + ] + }, + { + "name": "ChannelField", + "description": "One channel as a number. It renders a native number input with the limits and the step of that channel.", + "props": [ + { + "name": "channel", + "type": "ColorChannel", + "required": true, + "default": null, + "description": "The channel the field holds, for example `red`." + }, + { + "name": "class", + "type": "string", + "required": false, + "default": "''", + "description": "The CSS class names of the field." + }, + { + "name": "element", + "type": "HTMLInputElement | null", + "required": false, + "default": "null", + "description": "A bindable reference to the input element." + } + ], + "dataAttributes": [ + { + "name": "data-channel", + "description": "The channel of the field." + }, + { + "name": "data-color-picker-channel-field", + "description": "" + }, + { + "name": "data-disabled", + "description": "Present while the picker is disabled." + }, + { + "name": "data-focused", + "description": "Present while the field has the focus." + }, + { + "name": "data-invalid", + "description": "Present while the color is invalid." + }, + { + "name": "data-readonly", + "description": "Present while the picker is read-only." + } + ] + }, + { + "name": "SwatchList", + "description": "A set of colors to choose from. It renders a div with role=\"listbox\", and each ColorPicker.Swatch in it is an option.", + "props": [ + { + "name": "children", + "type": "Snippet", + "required": false, + "default": null, + "description": "The content: one `ColorPicker.Swatch` for each color." + }, + { + "name": "class", + "type": "string", + "required": false, + "default": "''", + "description": "The CSS class names of the list." + }, + { + "name": "element", + "type": "HTMLDivElement | null", + "required": false, + "default": "null", + "description": "A bindable reference to the list element." + } + ], + "dataAttributes": [ + { + "name": "data-color-picker-swatch-list", + "description": "" + }, + { + "name": "data-disabled", + "description": "Present while the picker is disabled." + }, + { + "name": "data-readonly", + "description": "Present while the picker is read-only." + } + ] + }, + { + "name": "Swatch", + "description": "One color to choose. In a list it is an option with role=\"option\". Outside a list it shows a color and answers nothing. The color is in --color-picker-swatch-color.", + "props": [ + { + "name": "color", + "type": "string", + "required": true, + "default": null, + "description": "The color of the swatch, as text." + }, + { + "name": "aria-label", + "type": "string", + "required": false, + "default": null, + "description": "The name of the swatch, for the screen reader. The default is the text of its color." + }, + { + "name": "children", + "type": "Snippet", + "required": false, + "default": null, + "description": "The content. Without it, the swatch is an empty element that your CSS paints." + }, + { + "name": "class", + "type": "string", + "required": false, + "default": "''", + "description": "The CSS class names of the swatch." + }, + { + "name": "element", + "type": "HTMLDivElement | null", + "required": false, + "default": "null", + "description": "A bindable reference to the swatch element." + } + ], + "dataAttributes": [ + { + "name": "data-color", + "description": "The color of the swatch, as the consumer wrote it." + }, + { + "name": "data-color-picker-swatch", + "description": "" + }, + { + "name": "data-disabled", + "description": "Present while the picker is disabled." + }, + { + "name": "data-focus-visible", + "description": "Present while the focus on the swatch must be visible (keyboard modality)." + }, + { + "name": "data-focused", + "description": "Present while the swatch has the focus." + }, + { + "name": "data-selected", + "description": "Present while the color of the swatch is the color of the picker." + } + ] + }, + { + "name": "Preview", + "description": "The color of now. It renders a div with the color in --color-picker-swatch-color, and it has no name and no role.", + "props": [ + { + "name": "children", + "type": "Snippet", + "required": false, + "default": null, + "description": "The content. Without it, the preview is an empty element that your CSS paints." + }, + { + "name": "class", + "type": "string", + "required": false, + "default": "''", + "description": "The CSS class names of the preview." + }, + { + "name": "element", + "type": "HTMLDivElement | null", + "required": false, + "default": "null", + "description": "A bindable reference to the preview element." + } + ], + "dataAttributes": [ + { + "name": "data-color", + "description": "The text of the color, in the format of the root." + }, + { + "name": "data-color-picker-preview", + "description": "" + }, + { + "name": "data-disabled", + "description": "Present while the picker is disabled." + } + ] + }, + { + "name": "EyeDropper", + "description": "Takes a color from the screen. It renders a button that opens the eye dropper of the browser.", + "props": [ + { + "name": "class", + "type": "string", + "required": false, + "default": "''", + "description": "The CSS class names of the button." + }, + { + "name": "element", + "type": "HTMLButtonElement | null", + "required": false, + "default": "null", + "description": "A bindable reference to the button element." + } + ], + "dataAttributes": [ + { + "name": "data-color-picker-eye-dropper", + "description": "" + }, + { + "name": "data-disabled", + "description": "Present while the picker is disabled." + }, + { + "name": "data-open", + "description": "Present while the eye dropper of the browser is open. The browser takes up to two seconds to paint it, thus this attribute is what tells the reader that the press did something." + }, + { + "name": "data-unsupported", + "description": "Present while the browser has no eye dropper." + } + ] + } + ] +} diff --git a/docs/src/content/color-picker/demos/alpha.svelte b/docs/src/content/color-picker/demos/alpha.svelte new file mode 100644 index 0000000..a7e8f7a --- /dev/null +++ b/docs/src/content/color-picker/demos/alpha.svelte @@ -0,0 +1,56 @@ + + + + + Overlay + + + + + + + + + + + +
+ +
+ +

{value}

+
diff --git a/docs/src/content/color-picker/demos/channels.svelte b/docs/src/content/color-picker/demos/channels.svelte new file mode 100644 index 0000000..3bc7cf1 --- /dev/null +++ b/docs/src/content/color-picker/demos/channels.svelte @@ -0,0 +1,39 @@ + + + + + Exact color + + +
+ + + + + +
+
diff --git a/docs/src/content/color-picker/demos/hero.svelte b/docs/src/content/color-picker/demos/hero.svelte new file mode 100644 index 0000000..57a70a8 --- /dev/null +++ b/docs/src/content/color-picker/demos/hero.svelte @@ -0,0 +1,46 @@ + + + + + Brand color + + + + + + +
+ +
+ + + + +
+
+
diff --git a/docs/src/content/color-picker/demos/swatches.svelte b/docs/src/content/color-picker/demos/swatches.svelte new file mode 100644 index 0000000..6f0bb9e --- /dev/null +++ b/docs/src/content/color-picker/demos/swatches.svelte @@ -0,0 +1,48 @@ + + + + + Label color + + + + {#each palette as color (color)} + + {/each} + + +
+ + + + + +
+
diff --git a/docs/src/content/color-picker/index.md b/docs/src/content/color-picker/index.md new file mode 100644 index 0000000..f9463d0 --- /dev/null +++ b/docs/src/content/color-picker/index.md @@ -0,0 +1,113 @@ +--- +title: ColorPicker +description: A form field for one color, with a square of saturation against brightness, a slider for each channel, a hex field, number fields, swatches, the eye dropper of the browser, and a hidden input for the form. +--- + + + +# ColorPicker + +`ColorPicker` is a form field for one color. The user moves a thumb in the square, moves a slider, writes a hex text, or takes a color from the screen. The color is held as hue, saturation and brightness, and the value that goes out is text. + + + +## Anatomy + +`ColorPicker.Root` holds the color. `ColorPicker.Area` is the square of two channels, with `ColorPicker.AreaThumb` in it. `ColorPicker.Slider` is the track of one channel, with `ColorPicker.SliderThumb` in it. + +`ColorPicker.HexField` is the color as text. `ColorPicker.ChannelField` is one channel as a number. `ColorPicker.SwatchList` holds the `ColorPicker.Swatch` elements. `ColorPicker.Preview` shows the color, and `ColorPicker.EyeDropper` takes one from the screen. + +```svelte + + + + Brand color + + + + + + + + + +``` + +## Value + +Use `bind:value` to give the root your state. Use `value` with `onChange` and `controlledValue` to hold the state yourself. The root then reports each change, and it does not write `value` back. + +The value is text. A hex color, `rgb()`, `hsl()` and `hsb()` all come in, and `format` decides what goes out: `hex`, `rgb`, `hsl` or `hsb`. A text that names no color leaves the color that is there. + +`onChange` runs on each change, also on each move of a drag. `onChangeEnd` runs when a sequence of changes ends: at the release of a key, and at the end of a drag. + +## The model of the color + +The color is held as hue, saturation and brightness. The square keeps its shape at each hue in that model, and it does not in red-green-blue. + +A black and a gray say nothing about the hue. The picker keeps the hue that was there. The thumb in the square stays where the user left it, and the hue slider does not jump back to red. + +## The square and the sliders + +`ColorPicker.Area` is the saturation against the brightness. The vertical axis counts from the bottom: white is at the top left corner, and black is at the bottom. Give `xChannel` and `yChannel` for two other channels. + +`ColorPicker.Slider` moves one channel: `hue`, `saturation`, `brightness`, `lightness`, `alpha`, `red`, `green` or `blue`. Give `orientation="vertical"` for a track that goes up. + +The arrows step, `Shift` with an arrow moves a large step, and `Home` and `End` go to the ends. The horizontal arrows follow the text direction. The hue is a circle: one step past red comes back to red. + +## Alpha + +Give `alpha` for a color with an alpha, and a `ColorPicker.Slider` with `channel="alpha"`. The value then holds the fourth number: `#3366cc80` in hex, and `rgba(51, 102, 204, 0.5)` in rgb. + +Without `alpha` the text holds only the three color channels. A picker with no alpha slider must not answer a number nobody can change. + + + +## The fields + +`ColorPicker.HexField` reads its text at `Enter` and when the focus leaves. What the user writes stays in the field until then, thus a half written color is not read on each key. A text that names no color goes back to the color of the picker. + +`ColorPicker.ChannelField` is a native number field with the limits and the step of its channel. Red goes from 0 to 255, the hue from 0 to 360, and the alpha from 0 to 1. + + + +## Swatches and the eye dropper + +`ColorPicker.SwatchList` is a listbox, and each `ColorPicker.Swatch` in it is an option. One swatch is in the tab order, the arrows move between them, and `Enter` or the space bar takes the color. A swatch outside a list shows a color and answers nothing. + +`ColorPicker.EyeDropper` opens the eye dropper of the browser, and the color of the press becomes the color of the picker. A browser without it gets `data-unsupported`, which your CSS can hide. Test for it before you make it the one way to choose a color. + +The browser takes up to two seconds to paint its eye dropper, because it must first read the screen. Give `data-open` a style of its own: the button carries it, and `aria-busy`, from the press until the color arrives. Without that style the button does not move, and the reader presses it again. + + + +## Style + +The picker paints nothing of its own. These custom properties give your CSS what it needs: + +- `--color-picker-value` on the root: the color, with its alpha. +- `--color-picker-hue` and `--color-picker-hue-color`: the hue as a number, and as the color at full saturation and brightness. The square is two gradients over that color. +- `--color-picker-area-x` and `--color-picker-area-y` on the area: the position of the thumb, in percent. +- `--color-picker-slider-start`, `--color-picker-slider-end` and `--color-picker-slider-percent` on a slider: the two ends of its channel in the color of now, and the position of its thumb. +- `--color-picker-swatch-color` on a swatch and on the preview. + +## Forms + +Give `name` for the color of the field. The root renders a hidden input with the text of the color, and a `` reset takes the first color back. `invalid` marks the color as wrong. + +## API reference + + diff --git a/docs/src/content/pin-input/api.json b/docs/src/content/pin-input/api.json new file mode 100644 index 0000000..f800f74 --- /dev/null +++ b/docs/src/content/pin-input/api.json @@ -0,0 +1,326 @@ +{ + "component": "pin-input", + "parts": [ + { + "name": "Root", + "description": "The value, the cells and the focus between them. It renders a div with role=\"group\", named by PinInput.Label.", + "props": [ + { + "name": "id", + "type": "string", + "required": false, + "default": null, + "description": "A stable id, from which the component makes its internal ARIA ids. Give one on a server." + }, + { + "name": "value", + "type": "string", + "required": false, + "default": null, + "description": "The value. It is never longer than `length`, and it has no holes: the characters fill the\ncells from the first one. You can bind it with `bind:value`." + }, + { + "name": "defaultValue", + "type": "string", + "required": false, + "default": null, + "description": "The value at the start, for when you give no `value`." + }, + { + "name": "controlledValue", + "type": "boolean", + "required": false, + "default": "false", + "description": "Give your own code full control of the value. The component stops to write back to `value`,\nand it reports only through `onChange`. Thus the parent can refuse a change." + }, + { + "name": "onChange", + "type": "(value: string, details: PinInputChangeDetails) => void", + "required": false, + "default": null, + "description": "The component calls it on each change." + }, + { + "name": "onComplete", + "type": "(value: string) => void", + "required": false, + "default": null, + "description": "The component calls it one time, when the last cell takes a character." + }, + { + "name": "length", + "type": "number", + "required": false, + "default": "6", + "description": "The count of cells. The default is 6." + }, + { + "name": "type", + "type": "PinInputType", + "required": false, + "default": "'numeric'", + "description": "Which characters the cells accept: only the digits, the letters, or both. The default is\n`numeric`, which also sets the numeric keyboard on a telephone." + }, + { + "name": "pattern", + "type": "RegExp", + "required": false, + "default": null, + "description": "A test of your own for one character. It replaces the test of `type`." + }, + { + "name": "otp", + "type": "boolean", + "required": false, + "default": "false", + "description": "Marks the cells as a code from a message. The telephone then offers the code of the last\nmessage above the keyboard, and one touch fills each cell." + }, + { + "name": "mask", + "type": "boolean", + "required": false, + "default": "false", + "description": "Hides the characters, as a password field does." + }, + { + "name": "placeholder", + "type": "string", + "required": false, + "default": null, + "description": "The text of an empty cell." + }, + { + "name": "blurOnComplete", + "type": "boolean", + "required": false, + "default": "false", + "description": "Moves the focus off the last cell when the value is complete." + }, + { + "name": "disabled", + "type": "boolean", + "required": false, + "default": "false", + "description": "Disables each cell. The cells leave the tab order." + }, + { + "name": "readonly", + "type": "boolean", + "required": false, + "default": "false", + "description": "Keeps the cells in the tab order, but the value does not change." + }, + { + "name": "required", + "type": "boolean", + "required": false, + "default": "false", + "description": "Marks the value as necessary. Each cell gets `required`." + }, + { + "name": "invalid", + "type": "boolean", + "required": false, + "default": "false", + "description": "Marks the value as invalid. Each cell gets `aria-invalid`." + }, + { + "name": "name", + "type": "string", + "required": false, + "default": null, + "description": "The name of the hidden input that carries the whole value in a form." + }, + { + "name": "form", + "type": "string", + "required": false, + "default": null, + "description": "The id of the form that the hidden input belongs to, when the group is outside it." + }, + { + "name": "aria-label", + "type": "string", + "required": false, + "default": null, + "description": "The accessible name of the group, for when there is no `PinInput.Label`." + }, + { + "name": "aria-labelledby", + "type": "string", + "required": false, + "default": null, + "description": "The id of the element that gives the group its name, in place of `PinInput.Label`." + }, + { + "name": "aria-describedby", + "type": "string", + "required": false, + "default": null, + "description": "The id of the element that describes the group, for example an error message." + }, + { + "name": "children", + "type": "Snippet", + "required": false, + "default": null, + "description": "The content: the label and the cells." + }, + { + "name": "class", + "type": "string", + "required": false, + "default": "''", + "description": "The CSS class names of the root element." + }, + { + "name": "element", + "type": "HTMLDivElement | null", + "required": false, + "default": "null", + "description": "A bindable reference to the root element." + }, + { + "name": "context", + "type": "PinInputContext", + "required": false, + "default": null, + "description": "A bindable reference to the context, for a composition of your own." + } + ], + "dataAttributes": [ + { + "name": "data-complete", + "description": "Present while each cell has a character." + }, + { + "name": "data-disabled", + "description": "Present while the field is disabled." + }, + { + "name": "data-focus-visible", + "description": "Present while the focus in the field must be visible (keyboard modality)." + }, + { + "name": "data-focus-within", + "description": "Present while a cell has the focus." + }, + { + "name": "data-invalid", + "description": "Present while the value is invalid." + }, + { + "name": "data-pin-input-root", + "description": "" + }, + { + "name": "data-pin-input-value", + "description": "" + }, + { + "name": "data-readonly", + "description": "Present while the field is read-only." + }, + { + "name": "data-required", + "description": "Present while the field is necessary." + } + ] + }, + { + "name": "Label", + "description": "The accessible name of the group. It renders a span, and the root points at it with aria-labelledby. A press on it puts the focus on the first cell that is open.", + "props": [ + { + "name": "class", + "type": "string", + "required": false, + "default": "''", + "description": "The CSS class names of the label." + } + ], + "dataAttributes": [ + { + "name": "data-disabled", + "description": "Present while the field is disabled." + }, + { + "name": "data-pin-input-label", + "description": "" + } + ] + }, + { + "name": "Cell", + "description": "One character of the value. It renders a native input of one character, with its own name, its inputmode and its autocomplete.", + "props": [ + { + "name": "index", + "type": "number", + "required": false, + "default": null, + "description": "The index of the cell. Without it, the cells take the indexes in mount order." + }, + { + "name": "aria-label", + "type": "string", + "required": false, + "default": null, + "description": "The name of this cell, for the screen reader. The default is \"Digit 2 of 6\"." + }, + { + "name": "class", + "type": "string", + "required": false, + "default": "''", + "description": "The CSS class names of the cell." + }, + { + "name": "element", + "type": "HTMLInputElement | null", + "required": false, + "default": "null", + "description": "A bindable reference to the input element." + } + ], + "dataAttributes": [ + { + "name": "data-active", + "description": "Present on the cell that takes the next character." + }, + { + "name": "data-disabled", + "description": "Present while the field is disabled." + }, + { + "name": "data-filled", + "description": "Present while the cell has a character." + }, + { + "name": "data-focus-visible", + "description": "Present while the focus on the cell must be visible (keyboard modality)." + }, + { + "name": "data-focused", + "description": "Present while the cell has the focus." + }, + { + "name": "data-index", + "description": "The index of the cell, from 0." + }, + { + "name": "data-invalid", + "description": "Present while the value is invalid." + }, + { + "name": "data-pin-input-cell", + "description": "" + }, + { + "name": "data-readonly", + "description": "Present while the field is read-only." + } + ] + } + ] +} diff --git a/docs/src/content/pin-input/demos/alphanumeric.svelte b/docs/src/content/pin-input/demos/alphanumeric.svelte new file mode 100644 index 0000000..65434d8 --- /dev/null +++ b/docs/src/content/pin-input/demos/alphanumeric.svelte @@ -0,0 +1,24 @@ + + + + + Invitation code + +
+ {#each { length: 5 } as _, index (index)} + {#if index === 2} + + {/if} + + {/each} +
+

+ The cells take the letters and the digits. Paste a code to fill each cell. +

+
diff --git a/docs/src/content/pin-input/demos/hero.svelte b/docs/src/content/pin-input/demos/hero.svelte new file mode 100644 index 0000000..a005852 --- /dev/null +++ b/docs/src/content/pin-input/demos/hero.svelte @@ -0,0 +1,19 @@ + + + + + Verification code + +
+ {#each { length: 6 } as _, index (index)} + + {/each} +
+

Code: {value || '—'}

+
diff --git a/docs/src/content/pin-input/demos/masked.svelte b/docs/src/content/pin-input/demos/masked.svelte new file mode 100644 index 0000000..311d319 --- /dev/null +++ b/docs/src/content/pin-input/demos/masked.svelte @@ -0,0 +1,42 @@ + + + { + wrong = false; + unlocked = false; + }} + onComplete={check} + class="flex flex-col gap-2" +> + PIN +
+ {#each { length: 4 } as _, index (index)} + + {/each} +
+

+ {#if unlocked} + The PIN is correct. + {:else if wrong} + The PIN is not correct. Try 2468. + {:else} + Write the four digits of your PIN. + {/if} +

+
diff --git a/docs/src/content/pin-input/index.md b/docs/src/content/pin-input/index.md new file mode 100644 index 0000000..ebadbb0 --- /dev/null +++ b/docs/src/content/pin-input/index.md @@ -0,0 +1,86 @@ +--- +title: PinInput +description: A form field for a short code, with one real input per character, a paste that goes across the cells, the code of a message on a telephone, and a hidden input for the form. +--- + + + +# PinInput + +`PinInput` is a form field for a short code: a PIN, or the verification code of a message. Each character has its own cell. Each cell is a real input, thus the telephone shows the correct keyboard and the code of a message fills the cells with one touch. + + + +## Anatomy + +`PinInput.Root` holds the value, the focus and the keyboard. `PinInput.Cell` is one character, and you give one for each character of `length`. `PinInput.Label` names the group. + +```svelte + + + + Verification code + {#each { length: 6 } as _, index (index)} + + {/each} + +``` + +## Value + +Use `bind:value` to give the root your state. Use `value` with `onChange` and `controlledValue` to hold the state yourself. The root then reports each change, and it does not write `value` back. + +The value is one text, and it has no holes. The characters fill the cells from the first one. A press on a cell past the first empty one goes to that one. A character that goes away takes the ones after it one cell to the left, as in a field with one box. + +`onComplete` runs one time, when the last cell takes a character. Use it for the work that follows the code, for example the send of the form. `blurOnComplete` moves the focus off the last cell at the end. + +## The characters each cell takes + +`type` is `numeric`, `alphanumeric` or `alphabetic`. It decides which characters a cell accepts, and it sets `inputmode`: a telephone shows the numeric keyboard for a code of digits. + +Give `pattern` for a test of your own. It is a regular expression for one character, and it replaces the test of the type. + +A character the test refuses leaves no trace: the cell does not take it, and the focus does not move. + + + +## The code of a message + +Give `otp` for a code that arrives in a message. Each cell takes `autocomplete="one-time-code"`, thus a telephone offers the code of the last message above the keyboard. The code arrives in one cell, and it goes across the cells from there. + +A paste does the same, from the cell it starts in. The characters the type refuses are dropped, thus a code with a dash in it fills the cells of a numeric field. + +Without a `PinInput.Label` and without an `aria-label`, an `otp` group takes the name "Verification code" in the locale. + +## A PIN + +Give `mask` for a code that the shoulder of a stranger must not read. Each cell is a password field then. `placeholder` gives an empty cell a character to show. + + + +## Style + +Each cell shows its state in `data-filled`, `data-active`, `data-focused`, `data-focus-visible` and `data-invalid`. `data-active` is on the cell that takes the next character, also while nothing has the focus. + +The root carries `data-complete` when each cell has a character. + +The cell is the input, thus your CSS holds the text, the caret and the border. `caret-color: transparent` with a shape of your own gives you a caret that is not the one of the browser. + +## Forms + +Give `name` for the whole value of the field. The root renders a hidden input with the characters together, and a `` reset takes the first value back. `required` marks each cell as necessary, and `invalid` marks the value as wrong. + +## API reference + + diff --git a/docs/src/content/rating/api.json b/docs/src/content/rating/api.json new file mode 100644 index 0000000..342d396 --- /dev/null +++ b/docs/src/content/rating/api.json @@ -0,0 +1,353 @@ +{ + "component": "rating", + "parts": [ + { + "name": "Root", + "description": "The scale, the value and the keyboard. It renders a div with role=\"radiogroup\", or role=\"slider\" at a precision below 1, named by Rating.Label.", + "props": [ + { + "name": "id", + "type": "string", + "required": false, + "default": null, + "description": "A stable id, from which the component makes its internal ARIA ids. Give one on a server." + }, + { + "name": "value", + "type": "number", + "required": false, + "default": null, + "description": "The value, from 0 to `count`. 0 is no rating. You can bind it with `bind:value`." + }, + { + "name": "defaultValue", + "type": "number", + "required": false, + "default": null, + "description": "The value at the start, for when you give no `value`. The default is 0." + }, + { + "name": "controlledValue", + "type": "boolean", + "required": false, + "default": "false", + "description": "Give your own code full control of the value. The component stops to write back to `value`,\nand it reports only through `onChange`. Thus the parent can refuse a change." + }, + { + "name": "onChange", + "type": "(value: number, details: RatingChangeDetails) => void", + "required": false, + "default": null, + "description": "The component calls it on each change." + }, + { + "name": "onChangeEnd", + "type": "(value: number, details: RatingChangeDetails) => void", + "required": false, + "default": null, + "description": "The component calls it when a sequence of changes ends: at the release of a key, and at the\npress on an item. Use it for work that must not run on each key repeat." + }, + { + "name": "count", + "type": "number", + "required": false, + "default": "5", + "description": "The count of items. The default is 5." + }, + { + "name": "precision", + "type": "number", + "required": false, + "default": "1", + "description": "The distance between two values. The default is 1, which gives whole items. 0.5 gives half\nitems. A precision below 1 makes the rating a slider, because a radio group cannot say 3.5." + }, + { + "name": "allowClear", + "type": "boolean", + "required": false, + "default": "true", + "description": "A press on the item of the current value puts the value back to 0. `Delete` and `Backspace`\ndo the same. The default is on." + }, + { + "name": "disabled", + "type": "boolean", + "required": false, + "default": "false", + "description": "Disables the rating. The items leave the tab order, and the value does not change." + }, + { + "name": "readonly", + "type": "boolean", + "required": false, + "default": "false", + "description": "Keeps the items in the tab order, but the value does not change." + }, + { + "name": "required", + "type": "boolean", + "required": false, + "default": "false", + "description": "Marks the rating as necessary. The root gets `aria-required`." + }, + { + "name": "invalid", + "type": "boolean", + "required": false, + "default": "false", + "description": "Marks the value as invalid. The root gets `aria-invalid`." + }, + { + "name": "name", + "type": "string", + "required": false, + "default": null, + "description": "The name of the hidden input that carries the value in a form." + }, + { + "name": "form", + "type": "string", + "required": false, + "default": null, + "description": "The id of the form that the hidden input belongs to, when the rating is outside it." + }, + { + "name": "getItemLabel", + "type": "(value: number, count: number) => string", + "required": false, + "default": null, + "description": "The text of one item, for the screen reader. The default is \"3 of 5\" in the locale. The\nsecond argument is the count." + }, + { + "name": "getValueText", + "type": "(value: number, count: number) => string", + "required": false, + "default": null, + "description": "The text of the value, for `Rating.Output` and for the text a screen reader reads. The\ndefault is \"3 of 5\" in the locale, and \"No rating\" at 0." + }, + { + "name": "aria-label", + "type": "string", + "required": false, + "default": null, + "description": "The accessible name of the rating, for when there is no `Rating.Label`." + }, + { + "name": "aria-labelledby", + "type": "string", + "required": false, + "default": null, + "description": "The id of the element that gives the rating its name, in place of `Rating.Label`." + }, + { + "name": "aria-describedby", + "type": "string", + "required": false, + "default": null, + "description": "The id of the element that describes the rating, for example an error message." + }, + { + "name": "children", + "type": "Snippet", + "required": false, + "default": null, + "description": "The content: the label, the output and the items." + }, + { + "name": "class", + "type": "string", + "required": false, + "default": "''", + "description": "The CSS class names of the root element." + }, + { + "name": "element", + "type": "HTMLDivElement | null", + "required": false, + "default": "null", + "description": "A bindable reference to the root element." + }, + { + "name": "context", + "type": "RatingContext", + "required": false, + "default": null, + "description": "A bindable reference to the context, for a composition of your own." + } + ], + "dataAttributes": [ + { + "name": "data-disabled", + "description": "Present while the rating is disabled." + }, + { + "name": "data-focus-visible", + "description": "Present while the focus in the rating must be visible (keyboard modality)." + }, + { + "name": "data-focus-within", + "description": "Present while an item, or the root, has the focus." + }, + { + "name": "data-hovering", + "description": "Present while the pointer is on the items." + }, + { + "name": "data-invalid", + "description": "Present while the value is invalid." + }, + { + "name": "data-rating-input", + "description": "" + }, + { + "name": "data-rating-root", + "description": "" + }, + { + "name": "data-readonly", + "description": "Present while the rating is read-only." + }, + { + "name": "data-required", + "description": "Present while the rating is necessary." + } + ] + }, + { + "name": "Label", + "description": "The accessible name of the rating. It renders a span, and the root points at it with aria-labelledby. A press on it puts the focus on the rating.", + "props": [ + { + "name": "class", + "type": "string", + "required": false, + "default": "''", + "description": "The CSS class names of the label." + } + ], + "dataAttributes": [ + { + "name": "data-disabled", + "description": "Present while the rating is disabled." + }, + { + "name": "data-rating-label", + "description": "" + } + ] + }, + { + "name": "Output", + "description": "The text of the value. It renders an output element for the root, with aria-live=\"off\". The text follows the pointer while the pointer is on the items.", + "props": [ + { + "name": "children", + "type": "Snippet<[RatingOutputRenderState]>", + "required": false, + "default": null, + "description": "The content. As a snippet with one argument, it receives the render state: `value`,\n`displayValue`, `count` and `text`. Without it, the component shows `text`." + }, + { + "name": "class", + "type": "string", + "required": false, + "default": "''", + "description": "The CSS class names of the element." + } + ], + "dataAttributes": [ + { + "name": "data-disabled", + "description": "Present while the rating is disabled." + }, + { + "name": "data-rating-output", + "description": "" + } + ] + }, + { + "name": "Item", + "description": "One item of the scale, for example one star. It renders a span with role=\"radio\", or role=\"presentation\" at a precision below 1. The fill of the item is in --rating-item-fill.", + "props": [ + { + "name": "index", + "type": "number", + "required": false, + "default": null, + "description": "The index of the item. Without it, the items take the indexes in mount order." + }, + { + "name": "aria-label", + "type": "string", + "required": false, + "default": null, + "description": "The name of this item, for the screen reader. It replaces `getItemLabel` of the root." + }, + { + "name": "children", + "type": "Snippet<[RatingItemRenderState]> | Snippet", + "required": false, + "default": null, + "description": "The content. As a snippet with one argument, it receives the render state: `value`, `index`,\n`fill`, `selected`, `highlighted` and `focused`." + }, + { + "name": "class", + "type": "string", + "required": false, + "default": "''", + "description": "The CSS class names of the item." + }, + { + "name": "element", + "type": "HTMLSpanElement | null", + "required": false, + "default": "null", + "description": "A bindable reference to the item element." + } + ], + "dataAttributes": [ + { + "name": "data-disabled", + "description": "Present while the rating is disabled." + }, + { + "name": "data-focus-visible", + "description": "Present while the focus on the item must be visible (keyboard modality)." + }, + { + "name": "data-focused", + "description": "Present while the item has the focus." + }, + { + "name": "data-highlighted", + "description": "Present while the value, or the pointer, is at this item or past it." + }, + { + "name": "data-index", + "description": "The index of the item, from 0." + }, + { + "name": "data-partial", + "description": "Present while the item is more than empty and less than full." + }, + { + "name": "data-rating-item", + "description": "" + }, + { + "name": "data-readonly", + "description": "Present while the rating is read-only." + }, + { + "name": "data-selected", + "description": "Present while the item is the value." + }, + { + "name": "data-value", + "description": "The value of the item, from 1." + } + ] + } + ] +} diff --git a/docs/src/content/rating/demos/half.svelte b/docs/src/content/rating/demos/half.svelte new file mode 100644 index 0000000..adb8610 --- /dev/null +++ b/docs/src/content/rating/demos/half.svelte @@ -0,0 +1,28 @@ + + + +
+ {#each { length: 5 } as _, index (index)} + + + + + + + {/each} +
+ +
diff --git a/docs/src/content/rating/demos/hero.svelte b/docs/src/content/rating/demos/hero.svelte new file mode 100644 index 0000000..521c288 --- /dev/null +++ b/docs/src/content/rating/demos/hero.svelte @@ -0,0 +1,28 @@ + + + +
+ Quality + +
+
+ {#each { length: 5 } as _, index (index)} + + + + + + + {/each} +
+
diff --git a/docs/src/content/rating/demos/readonly.svelte b/docs/src/content/rating/demos/readonly.svelte new file mode 100644 index 0000000..57ed09b --- /dev/null +++ b/docs/src/content/rating/demos/readonly.svelte @@ -0,0 +1,38 @@ + + +
    + {#each reviews as review (review.name)} +
  • + {review.name} + `${score} of 5 stars from ${review.name}`} + aria-label="Score of {review.name}" + class="flex gap-0.5" + > + {#each { length: 5 } as _, index (index)} + + + + + + + {/each} + +
  • + {/each} +
diff --git a/docs/src/content/rating/index.md b/docs/src/content/rating/index.md new file mode 100644 index 0000000..f37ffa8 --- /dev/null +++ b/docs/src/content/rating/index.md @@ -0,0 +1,85 @@ +--- +title: Rating +description: A form field for a score on a small scale, with whole or half items, a preview that follows the pointer, the keyboard of a radio group, and a hidden input for the form. +--- + + + +# Rating + +`Rating` is a form field with a score on a small scale, for example five stars. The user answers with a press on an item, or with the arrows. The value follows the pointer before the press, thus the reader sees the answer of a press before it. + + + +## Anatomy + +`Rating.Root` holds the value and the keyboard. `Rating.Item` is one item of the scale, and you give one for each item of `count`. `Rating.Label` names the field, and `Rating.Output` shows the value as text. + +```svelte + + + + Quality + + {#each { length: 5 } as _, index (index)} + + {/each} + +``` + +## Value + +Use `bind:value` to give the root your state. Use `value` with `onChange` and `controlledValue` to hold the state yourself. The root then reports each change, and it does not write `value` back. Thus you can refuse a change. + +Use `defaultValue` when the root holds its own state. The default is 0, which is no score. + +`onChange` runs on each change. `onChangeEnd` runs when a sequence of changes ends: at the release of a key, and at a press on an item. A held key reports one end, at its release. + +`allowClear` is on. A press on the item of the value puts the value back to 0, and so do `Delete` and `Backspace`. Set it to `false` for a field that must hold a score after the first press. + +## Two shapes, one component + +With a precision of 1 the root is a radio group, and each item is a radio with its own name. That is what a screen reader reads best: each value is one item, and the focus follows the value. + +With a smaller precision the root is a slider, with `aria-valuenow` and `aria-valuetext`. A radio group cannot say 3.5. The root is the one tab stop then, and the items are decoration. + +The keyboard is the same in the two shapes. The arrows step by the precision, `Home` goes to the first item, and `End` goes to the last one. The horizontal arrows follow the text direction. + +## Half items + +Give `precision={0.5}`. A press on the left half of an item gives the half, and a press on the right half gives the whole item. The value that follows the pointer does the same. + + + +## Style + +Each item carries `--rating-item-fill`, from 0 to 1: 0 for an empty item, 0.5 for a half one, and 1 for a full one. A shape over the item with `width: calc(var(--rating-item-fill) * 100%)` gives you the half star, and no second element is necessary. + +The root carries `--rating-value`, `--rating-display-value` and `--rating-count`. `--rating-display-value` is the value under the pointer, thus your CSS can answer the pointer without a state of its own. + +Each item shows its state in `data-selected`, `data-highlighted`, `data-partial`, `data-focused` and `data-focus-visible`. + +## A score that is only to read + +`readonly` keeps the items in the tab order and stops each change. Use it for the score of somebody else, for example in a list of reviews. Give `getValueText` a text that says whose score it is. + + + +## Forms + +Give `name` for the value of the field. The root renders a hidden input with the value as a number, and a `` reset takes the first value back. `required` marks the field as necessary, and `invalid` marks the value as wrong. + +## API reference + + diff --git a/docs/src/lib/docs/nav.ts b/docs/src/lib/docs/nav.ts index a17d817..735b94f 100644 --- a/docs/src/lib/docs/nav.ts +++ b/docs/src/lib/docs/nav.ts @@ -26,7 +26,9 @@ export const nav: NavGroup[] = [ { slug: 'checkbox-group', title: 'CheckboxGroup' }, { slug: 'input', title: 'Input' }, { slug: 'numberfield', title: 'NumberField' }, + { slug: 'pin-input', title: 'PinInput' }, { slug: 'radio-group', title: 'RadioGroup' }, + { slug: 'rating', title: 'Rating' }, { slug: 'slider', title: 'Slider' }, { slug: 'switch', title: 'Switch' }, { slug: 'textarea', title: 'TextArea' }, @@ -38,6 +40,7 @@ export const nav: NavGroup[] = [ label: 'Pickers', items: [ { slug: 'autocomplete', title: 'Autocomplete' }, + { slug: 'color-picker', title: 'ColorPicker' }, { slug: 'combobox', title: 'ComboBox' }, { slug: 'listbox', title: 'ListBox' }, { slug: 'select', title: 'Select' }, diff --git a/packages/ui/package.json b/packages/ui/package.json index 3625d9d..5d30903 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -178,6 +178,21 @@ "svelte": "./dist/radio-group/index.js", "default": "./dist/radio-group/index.js" }, + "./rating": { + "types": "./dist/rating/index.d.ts", + "svelte": "./dist/rating/index.js", + "default": "./dist/rating/index.js" + }, + "./pin-input": { + "types": "./dist/pin-input/index.d.ts", + "svelte": "./dist/pin-input/index.js", + "default": "./dist/pin-input/index.js" + }, + "./color-picker": { + "types": "./dist/color-picker/index.d.ts", + "svelte": "./dist/color-picker/index.js", + "default": "./dist/color-picker/index.js" + }, "./select": { "types": "./dist/select/index.d.ts", "svelte": "./dist/select/index.js", diff --git a/packages/ui/src/lib/color-picker/README.md b/packages/ui/src/lib/color-picker/README.md new file mode 100644 index 0000000..4792f3b --- /dev/null +++ b/packages/ui/src/lib/color-picker/README.md @@ -0,0 +1,78 @@ +# ColorPicker + +## Description + +`ColorPicker` is a form field for one color. It holds a square of saturation against brightness, and a slider for each channel. It also holds a hex field, a number field for each channel, a set of swatches and the eye dropper of the browser. The color is held as hue, saturation and brightness, and the value that goes out is text. + +## Anatomy + +- `ColorPicker.Root` +- `ColorPicker.Label` +- `ColorPicker.Area` +- `ColorPicker.AreaThumb` +- `ColorPicker.Slider` +- `ColorPicker.SliderThumb` +- `ColorPicker.HexField` +- `ColorPicker.ChannelField` +- `ColorPicker.SwatchList` +- `ColorPicker.Swatch` +- `ColorPicker.Preview` +- `ColorPicker.EyeDropper` + +```svelte + + Brand color + + + + + + + + + +``` + +## Usage guidelines + +- Use `bind:value` for the state, or `value` with `onChange` and `controlledValue` to hold it yourself. +- `format` decides the text of the value: `hex`, `rgb`, `hsl` or `hsb`. The value that comes in can be in any of them. +- Use `alpha` for a color with an alpha. `ColorPicker.Slider` then accepts `channel="alpha"`. +- Use `onChangeEnd` for work that must not run on each move of a drag. +- Give the area a size and the thumb a size. Each thumb is positioned in percent. +- The CSS custom properties paint the picker: `--color-picker-value` on the root, `--color-picker-hue-color` for the square, `--color-picker-slider-start` and `--color-picker-slider-end` for a track, and `--color-picker-swatch-color` on a swatch and on the preview. +- Test for the eye dropper before you make it the one way to choose a color: a browser without it gets `data-unsupported`. + +## API reference + +- `ColorPicker.Root` + - `value?: string` + - `defaultValue?: string` + - `controlledValue?: boolean` + - `onChange?: (value, details) => void` + - `onChangeEnd?: (value, details) => void` + - `format?: 'hex' | 'rgb' | 'hsl' | 'hsb'` + - `alpha?: boolean` + - `disabled?: boolean`, `readonly?: boolean`, `invalid?: boolean` + - `name?: string`, `form?: string` +- `ColorPicker.Area` + - `xChannel?: ColorChannel`, `yChannel?: ColorChannel` +- `ColorPicker.Slider` + - `channel: ColorChannel` + - `orientation?: 'horizontal' | 'vertical'` +- `ColorPicker.ChannelField` + - `channel: ColorChannel` +- `ColorPicker.Swatch` + - `color: string` + - `aria-label?: string` + +## Accessibility + +- The root is a `role="group"` named by `ColorPicker.Label`, `aria-labelledby` or `aria-label`. +- `ColorPicker.AreaThumb` holds two native sliders, one for each axis, each with the name of its channel and with `aria-valuetext`. That gives a two-dimensional control a value a screen reader can read. The arrows move both axes from either of them. +- `ColorPicker.SliderThumb` holds one native slider with the name of its channel. +- The keyboard: the arrows step, `Shift` with an arrow and `PageUp` or `PageDown` move a large step, and `Home` and `End` go to the ends. The horizontal arrows follow the text direction. +- `ColorPicker.HexField` reads its text at `Enter` and when the focus leaves. A text that names no color goes back to the color of the picker. +- `ColorPicker.SwatchList` is a `role="listbox"`, and each swatch is an option. One swatch is in the tab order, the arrows move between them, and `Enter` or the space bar takes the color. +- `ColorPicker.Preview` has no name and no role: the color is already in the fields and in the sliders. +- The color in a form comes from a hidden input. A `` reset takes the first color back. diff --git a/packages/ui/src/lib/color-picker/TODO.md b/packages/ui/src/lib/color-picker/TODO.md new file mode 100644 index 0000000..73a6e9b --- /dev/null +++ b/packages/ui/src/lib/color-picker/TODO.md @@ -0,0 +1,17 @@ +# ColorPicker TODO + +## Goal + +Track ColorPicker work with a single mandatory TODO format. + +## Backlog + +- [x] [S][P0][Area: Architecture][Owner: Unassigned][Target: Done] Create the twelve parts of the picker with namespace exports. +- [x] [S][P0][Area: Architecture][Owner: Unassigned][Target: Done] Write the color math: the conversions, the channels, the parsing and the formats. +- [x] [S][P0][Area: Accessibility][Owner: Unassigned][Target: Done] Give the square two native sliders, one for each axis, each with its channel and its value. +- [x] [S][P0][Area: Keyboard][Owner: Unassigned][Target: Done] Handle the arrows, the large steps, Home and End, with the text direction. +- [x] [S][P0][Area: Pointer][Owner: Unassigned][Target: Done] Move the color on a press in the square and on a track, and drag with pointer capture. +- [x] [S][P0][Area: Forms][Owner: Unassigned][Target: Done] Send the color with a hidden input, and return to the default on a form reset. +- [ ] [S][P2][Area: API][Owner: Unassigned][Target: TBD] Add a channel field that is a text field, for the locales where a number field is hard to write in. +- [ ] [S][P2][Area: API][Owner: Unassigned][Target: TBD] Add the names of the colors, which a screen reader reads in place of a hex text. +- [ ] [C][P3][Area: Architecture][Owner: Unassigned][Target: TBD] Add the wide color spaces, which need a color model with more than the eight bits of a channel. diff --git a/packages/ui/src/lib/color-picker/area-thumb/color-picker-area-thumb.svelte b/packages/ui/src/lib/color-picker/area-thumb/color-picker-area-thumb.svelte new file mode 100644 index 0000000..8769685 --- /dev/null +++ b/packages/ui/src/lib/color-picker/area-thumb/color-picker-area-thumb.svelte @@ -0,0 +1,231 @@ + + +
+ handleKeyDown('x', event)} + onkeyup={commitKeyboard} + oninput={(event) => handleInput('x', event)} + onfocus={() => handleFocus('x')} + onblur={(event) => handleBlur('x', event)} + /> + handleKeyDown('y', event)} + onkeyup={commitKeyboard} + oninput={(event) => handleInput('y', event)} + onfocus={() => handleFocus('y')} + onblur={(event) => handleBlur('y', event)} + /> + {#if children} + {@render (children as Snippet<[ColorPickerAreaThumbRenderState]>)(renderState)} + {/if} +
diff --git a/packages/ui/src/lib/color-picker/area/color-picker-area.svelte b/packages/ui/src/lib/color-picker/area/color-picker-area.svelte new file mode 100644 index 0000000..bb376e4 --- /dev/null +++ b/packages/ui/src/lib/color-picker/area/color-picker-area.svelte @@ -0,0 +1,195 @@ + + +
+ {@render children?.()} +
diff --git a/packages/ui/src/lib/color-picker/channel-field/color-picker-channel-field.svelte b/packages/ui/src/lib/color-picker/channel-field/color-picker-channel-field.svelte new file mode 100644 index 0000000..9ed179f --- /dev/null +++ b/packages/ui/src/lib/color-picker/channel-field/color-picker-channel-field.svelte @@ -0,0 +1,95 @@ + + + diff --git a/packages/ui/src/lib/color-picker/eye-dropper/color-picker-eye-dropper.svelte b/packages/ui/src/lib/color-picker/eye-dropper/color-picker-eye-dropper.svelte new file mode 100644 index 0000000..2512ecf --- /dev/null +++ b/packages/ui/src/lib/color-picker/eye-dropper/color-picker-eye-dropper.svelte @@ -0,0 +1,79 @@ + + + diff --git a/packages/ui/src/lib/color-picker/hex-field/color-picker-hex-field.svelte b/packages/ui/src/lib/color-picker/hex-field/color-picker-hex-field.svelte new file mode 100644 index 0000000..06bdcd3 --- /dev/null +++ b/packages/ui/src/lib/color-picker/hex-field/color-picker-hex-field.svelte @@ -0,0 +1,105 @@ + + + diff --git a/packages/ui/src/lib/color-picker/index.parts.ts b/packages/ui/src/lib/color-picker/index.parts.ts new file mode 100644 index 0000000..677276e --- /dev/null +++ b/packages/ui/src/lib/color-picker/index.parts.ts @@ -0,0 +1,12 @@ +export { default as Root } from './root/color-picker-root.svelte'; +export { default as Label } from './label/color-picker-label.svelte'; +export { default as Area } from './area/color-picker-area.svelte'; +export { default as AreaThumb } from './area-thumb/color-picker-area-thumb.svelte'; +export { default as Slider } from './slider/color-picker-slider.svelte'; +export { default as SliderThumb } from './slider-thumb/color-picker-slider-thumb.svelte'; +export { default as HexField } from './hex-field/color-picker-hex-field.svelte'; +export { default as ChannelField } from './channel-field/color-picker-channel-field.svelte'; +export { default as SwatchList } from './swatch-list/color-picker-swatch-list.svelte'; +export { default as Swatch } from './swatch/color-picker-swatch.svelte'; +export { default as Preview } from './preview/color-picker-preview.svelte'; +export { default as EyeDropper } from './eye-dropper/color-picker-eye-dropper.svelte'; diff --git a/packages/ui/src/lib/color-picker/index.ts b/packages/ui/src/lib/color-picker/index.ts new file mode 100644 index 0000000..ec053ec --- /dev/null +++ b/packages/ui/src/lib/color-picker/index.ts @@ -0,0 +1,57 @@ +export * as ColorPicker from './index.parts.js'; + +export { default as ColorPickerRoot } from './root/color-picker-root.svelte'; +export { default as ColorPickerLabel } from './label/color-picker-label.svelte'; +export { default as ColorPickerArea } from './area/color-picker-area.svelte'; +export { default as ColorPickerAreaThumb } from './area-thumb/color-picker-area-thumb.svelte'; +export { default as ColorPickerSlider } from './slider/color-picker-slider.svelte'; +export { default as ColorPickerSliderThumb } from './slider-thumb/color-picker-slider-thumb.svelte'; +export { default as ColorPickerHexField } from './hex-field/color-picker-hex-field.svelte'; +export { default as ColorPickerChannelField } from './channel-field/color-picker-channel-field.svelte'; +export { default as ColorPickerSwatchList } from './swatch-list/color-picker-swatch-list.svelte'; +export { default as ColorPickerSwatch } from './swatch/color-picker-swatch.svelte'; +export { default as ColorPickerPreview } from './preview/color-picker-preview.svelte'; +export { default as ColorPickerEyeDropper } from './eye-dropper/color-picker-eye-dropper.svelte'; + +export type { + ColorPickerRootProps, + ColorPickerLabelProps, + ColorPickerAreaProps, + ColorPickerAreaThumbProps, + ColorPickerAreaThumbRenderState, + ColorPickerSliderProps, + ColorPickerSliderThumbProps, + ColorPickerSliderThumbRenderState, + ColorPickerHexFieldProps, + ColorPickerChannelFieldProps, + ColorPickerSwatchListProps, + ColorPickerSwatchProps, + ColorPickerPreviewProps, + ColorPickerEyeDropperProps +} from './types.js'; + +export { + getColorPickerContext, + setColorPickerContext, + useColorPickerContext, + type Color, + type ColorChannel, + type ColorFormat, + type ColorPickerChangeDetails, + type ColorPickerChangeReason, + type ColorPickerContext, + type ColorPickerSliderOrientation +} from './root/context.js'; + +export { + COLOR_CHANNEL_RANGES, + formatColor, + getColorChannel, + hsbToHsl, + hsbToRgb, + hslToHsb, + parseColor, + rgbToHsb, + toCssColor, + withColorChannel +} from './root/color.js'; diff --git a/packages/ui/src/lib/color-picker/label/color-picker-label.svelte b/packages/ui/src/lib/color-picker/label/color-picker-label.svelte new file mode 100644 index 0000000..d26feb4 --- /dev/null +++ b/packages/ui/src/lib/color-picker/label/color-picker-label.svelte @@ -0,0 +1,34 @@ + + + + {@render children?.()} + diff --git a/packages/ui/src/lib/color-picker/preview/color-picker-preview.svelte b/packages/ui/src/lib/color-picker/preview/color-picker-preview.svelte new file mode 100644 index 0000000..8e95af3 --- /dev/null +++ b/packages/ui/src/lib/color-picker/preview/color-picker-preview.svelte @@ -0,0 +1,43 @@ + + +
+ {@render children?.()} +
diff --git a/packages/ui/src/lib/color-picker/root/color-picker-form-test.svelte b/packages/ui/src/lib/color-picker/root/color-picker-form-test.svelte new file mode 100644 index 0000000..4f947b8 --- /dev/null +++ b/packages/ui/src/lib/color-picker/root/color-picker-form-test.svelte @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/packages/ui/src/lib/color-picker/root/color-picker-root.svelte b/packages/ui/src/lib/color-picker/root/color-picker-root.svelte new file mode 100644 index 0000000..d214d57 --- /dev/null +++ b/packages/ui/src/lib/color-picker/root/color-picker-root.svelte @@ -0,0 +1,365 @@ + + +
+ {#if name} + + {/if} + {@render children?.()} +
diff --git a/packages/ui/src/lib/color-picker/root/color-picker-ssr.test.ts b/packages/ui/src/lib/color-picker/root/color-picker-ssr.test.ts new file mode 100644 index 0000000..7511cf2 --- /dev/null +++ b/packages/ui/src/lib/color-picker/root/color-picker-ssr.test.ts @@ -0,0 +1,46 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest'; +import { render } from 'svelte/server'; +import ColorPickerTest from './color-picker-test.svelte'; + +function getTag(source: string, marker: string) { + const markerIndex = source.indexOf(marker); + if (markerIndex === -1) return ''; + const tagStart = source.lastIndexOf('<', markerIndex); + const tagEnd = source.indexOf('>', markerIndex); + if (tagStart === -1 || tagEnd === -1) return ''; + return source.slice(tagStart, tagEnd + 1); +} + +describe('ColorPicker SSR', () => { + it('renders the color, the position of each thumb and the fields before hydration', () => { + const { body } = render(ColorPickerTest, { props: { defaultValue: '#3366cc' } }); + + const root = getTag(body, 'data-testid="root"'); + expect(root).toContain('role="group"'); + expect(root).toContain('--color-picker-hue: 220'); + expect(getTag(body, 'data-testid="area"')).toContain('--color-picker-area-x: 75%'); + expect(getTag(body, 'data-testid="area-thumb"')).toContain('left: 75%'); + expect(getTag(body, 'data-testid="hue-thumb"')).toContain('left: 61.11%'); + expect(getTag(body, 'data-testid="hex"')).toContain('value="#3366cc"'); + expect(getTag(body, 'data-testid="red"')).toContain('value="51"'); + }); + + it('marks the swatch of the color before hydration', () => { + const { body } = render(ColorPickerTest, { props: { defaultValue: '#00ff00' } }); + + expect(getTag(body, 'data-testid="swatch-1"')).toContain('aria-selected="true"'); + expect(getTag(body, 'data-testid="swatch-0"')).toContain('aria-selected="false"'); + }); + + it('reports nothing while rendering', () => { + const changes: unknown[] = []; + + render(ColorPickerTest, { + props: { defaultValue: '#3366cc', onChange: (value: unknown) => changes.push(value) } + }); + + expect(changes).toEqual([]); + }); +}); diff --git a/packages/ui/src/lib/color-picker/root/color-picker-test.svelte b/packages/ui/src/lib/color-picker/root/color-picker-test.svelte new file mode 100644 index 0000000..7806996 --- /dev/null +++ b/packages/ui/src/lib/color-picker/root/color-picker-test.svelte @@ -0,0 +1,95 @@ + + +
+ + + {#if withLabel} + Brand color + {/if} + + + + + + + {#if alpha} + + + + {/if} + + + + + + + {#each swatches as swatch, index (swatch)} + + {/each} + + Pick + + + {JSON.stringify(value)} +
diff --git a/packages/ui/src/lib/color-picker/root/color-picker.test.ts b/packages/ui/src/lib/color-picker/root/color-picker.test.ts new file mode 100644 index 0000000..6c436f3 --- /dev/null +++ b/packages/ui/src/lib/color-picker/root/color-picker.test.ts @@ -0,0 +1,377 @@ +import { tick } from 'svelte'; +import { describe, expect, it, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import { userEvent } from 'vitest/browser'; +import { expectNoFalseFocusAttributes } from '../../test-utils/focus-contract'; +import ColorPickerFormTest from './color-picker-form-test.svelte'; +import ColorPickerTest from './color-picker-test.svelte'; + +function byTestId(id: string): T { + const element = document.querySelector(`[data-testid="${id}"]`); + if (!element) throw new Error(`No element with data-testid="${id}"`); + return element; +} + +function boundValue(): string | null { + return JSON.parse(byTestId('bound-value').textContent ?? 'null'); +} + +function areaInput(axis: 'x' | 'y'): HTMLInputElement { + const element = document.querySelector(`[data-axis="${axis}"]`); + if (!element) throw new Error(`No area input for the ${axis} axis`); + return element; +} + +function sliderInput(channel: string): HTMLInputElement { + const element = document.querySelector( + `[data-color-picker-slider-input="true"][data-channel="${channel}"]` + ); + if (!element) throw new Error(`No slider input for ${channel}`); + return element; +} + +/** Dispatches a pointer event at a fraction of an element. */ +async function pointerAt(target: Element, type: string, fractionX: number, fractionY = 0.5) { + const rect = target.getBoundingClientRect(); + target.dispatchEvent( + new PointerEvent(type, { + clientX: rect.left + rect.width * fractionX, + clientY: rect.top + rect.height * fractionY, + pointerId: 1, + button: 0, + buttons: type === 'pointerup' ? 0 : 1, + bubbles: true, + cancelable: true, + composed: true + }) + ); + await tick(); +} + +describe('ColorPicker', () => { + it('renders a group named by the label, with a slider for each axis of the square', async () => { + const screen = render(ColorPickerTest, { defaultValue: '#3366cc' }); + + const group = screen.getByRole('group', { name: 'Brand color' }); + expect(group.element()).toBe(byTestId('root')); + + expect(areaInput('x').getAttribute('aria-label')).toBe('Saturation'); + expect(areaInput('x').getAttribute('aria-valuetext')).toBe('75%'); + expect(areaInput('y').getAttribute('aria-label')).toBe('Brightness'); + expect(areaInput('y').getAttribute('aria-valuetext')).toBe('80%'); + expect(sliderInput('hue').getAttribute('aria-label')).toBe('Hue'); + expect(sliderInput('hue').getAttribute('aria-valuetext')).toBe('220°'); + }); + + it('gives the color, the hue and the alpha to the CSS of the page', async () => { + render(ColorPickerTest, { defaultValue: '#3366cc' }); + + const root = byTestId('root'); + expect(root.style.getPropertyValue('--color-picker-value')).toBe('rgba(51, 102, 204, 1)'); + expect(root.style.getPropertyValue('--color-picker-hue')).toBe('220'); + expect(byTestId('area').style.getPropertyValue('--color-picker-area-x')).toBe('75%'); + expect(byTestId('area').style.getPropertyValue('--color-picker-area-y')).toBe('80%'); + expect(byTestId('preview').getAttribute('data-color')).toBe('#3366cc'); + }); + + it('moves the saturation and the brightness with a press in the square', async () => { + const ends: string[] = []; + render(ColorPickerTest, { defaultValue: '#ff0000', onChangeEnd: (value) => ends.push(value) }); + + const area = byTestId('area'); + await pointerAt(area, 'pointerdown', 0.25, 0.5); + + expect(Number(areaInput('x').value)).toBeCloseTo(25, 0); + expect(Number(areaInput('y').value)).toBeCloseTo(50, 0); + expect(byTestId('area').getAttribute('data-dragging')).toBe('true'); + + await pointerAt(area, 'pointerup', 0.25, 0.5); + + expect(byTestId('area').hasAttribute('data-dragging')).toBe(false); + expect(ends).toHaveLength(1); + }); + + it('moves the hue with a press on its track', async () => { + render(ColorPickerTest, { defaultValue: '#ff0000' }); + + await pointerAt(byTestId('hue-slider'), 'pointerdown', 0.5); + + expect(boundValue()).toBe('#00ffff'); + expect(sliderInput('hue').value).toBe('180'); + }); + + it('mirrors the tracks in a right-to-left context', async () => { + render(ColorPickerTest, { defaultValue: '#ff0000', dir: 'rtl' }); + + await pointerAt(byTestId('hue-slider'), 'pointerdown', 0.25); + + expect(sliderInput('hue').value).toBe('270'); + }); + + it('moves one step with an arrow, and ten with Shift', async () => { + render(ColorPickerTest, { defaultValue: '#3366cc' }); + + sliderInput('hue').focus(); + await userEvent.keyboard('{ArrowRight}'); + expect(sliderInput('hue').value).toBe('221'); + + await userEvent.keyboard('{Shift>}{ArrowRight}{/Shift}'); + expect(sliderInput('hue').value).toBe('236'); + + await userEvent.keyboard('{Home}'); + expect(sliderInput('hue').value).toBe('0'); + + await userEvent.keyboard('{End}'); + expect(sliderInput('hue').value).toBe('360'); + }); + + it('takes the hue past the end of the circle back to the start', async () => { + render(ColorPickerTest, { defaultValue: '#ff0000' }); + + sliderInput('hue').focus(); + await userEvent.keyboard('{ArrowLeft}'); + + expect(sliderInput('hue').value).toBe('359'); + }); + + it('moves both axes of the square from either of its two sliders', async () => { + render(ColorPickerTest, { defaultValue: '#3366cc' }); + + areaInput('x').focus(); + await userEvent.keyboard('{ArrowRight}'); + expect(Number(areaInput('x').value)).toBe(76); + + await userEvent.keyboard('{ArrowUp}'); + expect(Number(areaInput('y').value)).toBe(81); + + expect(areaInput('x').getAttribute('data-axis')).toBe('x'); + expectNoFalseFocusAttributes(); + }); + + it('reports one end for a held key, and one for each drag', async () => { + const ends: string[] = []; + render(ColorPickerTest, { defaultValue: '#3366cc', onChangeEnd: (value) => ends.push(value) }); + + sliderInput('hue').focus(); + await userEvent.keyboard('{ArrowRight}{ArrowRight}'); + + expect(ends).toHaveLength(2); + }); + + it('reads the hex field when the focus leaves it, and at Enter', async () => { + render(ColorPickerTest, { defaultValue: '#ff0000' }); + + const hex = byTestId('hex'); + expect(hex.value).toBe('#ff0000'); + + await userEvent.fill(hex, '#00ff00'); + await userEvent.keyboard('{Enter}'); + expect(boundValue()).toBe('#00ff00'); + + await userEvent.fill(hex, '#0000ff'); + await userEvent.click(byTestId('after')); + expect(boundValue()).toBe('#0000ff'); + }); + + it('takes the hex field back to the color when the text names no color', async () => { + render(ColorPickerTest, { defaultValue: '#ff0000' }); + + const hex = byTestId('hex'); + await userEvent.fill(hex, 'not a color'); + await userEvent.click(byTestId('after')); + await tick(); + + expect(boundValue()).toBe('#ff0000'); + expect(hex.value).toBe('#ff0000'); + }); + + it('holds one channel in a number field, with the limits of that channel', async () => { + render(ColorPickerTest, { defaultValue: '#3366cc' }); + + const red = byTestId('red'); + expect(red.value).toBe('51'); + expect(red.getAttribute('max')).toBe('255'); + expect(red.getAttribute('aria-label')).toBe('Red'); + + await userEvent.fill(red, '255'); + + expect(boundValue()).toBe('#ff66cc'); + }); + + it('keeps the hue of a color that goes to black', async () => { + render(ColorPickerTest, { defaultValue: '#3366cc' }); + + await userEvent.fill(byTestId('red'), '0'); + await userEvent.fill(byTestId('green'), '0'); + // The color is blue here, and the last step takes it to black: the hue of that blue stays, + // thus the thumb of the hue does not jump back to red. + const hue = sliderInput('hue').value; + await userEvent.fill(byTestId('blue'), '0'); + + expect(boundValue()).toBe('#000000'); + expect(sliderInput('hue').value).toBe(hue); + expect(hue).toBe('240'); + }); + + it('holds an alpha when the picker asks for one', async () => { + render(ColorPickerTest, { defaultValue: '#ff0000', alpha: true, format: 'rgb' }); + + expect(sliderInput('alpha').getAttribute('aria-label')).toBe('Alpha'); + + await pointerAt(byTestId('alpha-slider'), 'pointerdown', 0.5); + + expect(boundValue()).toBe('rgba(255, 0, 0, 0.5)'); + }); + + it('names each swatch, and marks the one that is the color', async () => { + const screen = render(ColorPickerTest, { defaultValue: '#ff0000' }); + + const list = screen.getByRole('listbox', { name: 'Color swatches' }); + expect(list.element()).toBe(byTestId('swatches')); + expect(byTestId('swatch-0').getAttribute('aria-selected')).toBe('true'); + expect(byTestId('swatch-1').getAttribute('aria-selected')).toBe('false'); + expect(byTestId('swatch-1').getAttribute('aria-label')).toBe('#00ff00'); + }); + + it('takes the color of a swatch at a press, and at Enter', async () => { + render(ColorPickerTest, { defaultValue: '#ff0000' }); + + await userEvent.click(byTestId('swatch-1')); + expect(boundValue()).toBe('#00ff00'); + + byTestId('swatch-1').focus(); + await userEvent.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(byTestId('swatch-2')); + + await userEvent.keyboard('{Enter}'); + expect(boundValue()).toBe('#0000ff'); + }); + + it('holds one swatch in the tab order', async () => { + render(ColorPickerTest, { defaultValue: '#00ff00' }); + + expect(byTestId('swatch-0').tabIndex).toBe(-1); + expect(byTestId('swatch-1').tabIndex).toBe(0); + }); + + it('hides the eye dropper of a browser that has none', async () => { + render(ColorPickerTest); + + const button = byTestId('eye-dropper'); + expect(button.hasAttribute('data-unsupported')).toBe('EyeDropper' in window ? false : true); + }); + + it('reports one change for one move of the pointer in the square', async () => { + const changes: string[] = []; + render(ColorPickerTest, { defaultValue: '#ff0000', onChange: (value) => changes.push(value) }); + + await pointerAt(byTestId('area'), 'pointerdown', 0.5, 0.5); + + // The two axes are one color. Two writes report a color between them that the pointer was + // never on, and they make a consumer do its work two times for each move. + expect(changes).toHaveLength(1); + expect(changes[0]).toBe(boundValue()); + }); + + it('answers Home and End on the axis that has the focus', async () => { + render(ColorPickerTest, { defaultValue: '#3366cc' }); + + const saturationBefore = areaInput('x').value; + areaInput('y').focus(); + await userEvent.keyboard('{Home}'); + + // Home on the brightness takes the brightness to its lowest, and leaves the saturation. + expect(areaInput('y').value).toBe('0'); + expect(areaInput('x').value).toBe(saturationBefore); + + areaInput('x').focus(); + await userEvent.keyboard('{End}'); + expect(areaInput('x').value).toBe('100'); + }); + + it('reports no end from a hex field that holds a text of no color', async () => { + const ends: string[] = []; + render(ColorPickerTest, { defaultValue: '#3366cc', onChangeEnd: (value) => ends.push(value) }); + + const hex = byTestId('hex'); + hex.focus(); + await userEvent.fill(hex, 'not a color'); + byTestId('after').focus(); + await tick(); + + expect(ends).toEqual([]); + // The field cannot hold a text that is not the color. + expect(hex.value).toBe('#3366cc'); + expect(boundValue()).toBe('#3366cc'); + }); + + it('reports no end from a number field the reader only passed through', async () => { + const ends: string[] = []; + render(ColorPickerTest, { defaultValue: '#3366cc', onChangeEnd: (value) => ends.push(value) }); + + byTestId('red').focus(); + byTestId('green').focus(); + byTestId('after').focus(); + await tick(); + + expect(ends).toEqual([]); + }); + + it('changes nothing while disabled, and leaves the controls out of the tab order', async () => { + const onChange = vi.fn(); + render(ColorPickerTest, { defaultValue: '#ff0000', disabled: true, onChange }); + + await pointerAt(byTestId('hue-slider'), 'pointerdown', 0.5); + expect(boundValue()).toBe('#ff0000'); + + await userEvent.keyboard('{Tab}{Tab}'); + expect(document.activeElement).toBe(byTestId('after')); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('changes nothing while readonly', async () => { + render(ColorPickerTest, { defaultValue: '#ff0000', readonly: true }); + + await pointerAt(byTestId('hue-slider'), 'pointerdown', 0.5); + await userEvent.click(byTestId('swatch-1')); + + expect(boundValue()).toBe('#ff0000'); + }); + + it('refuses the change in controlled mode until the parent sends the color', async () => { + const onChange = vi.fn(); + render(ColorPickerTest, { value: '#ff0000', controlledValue: true, onChange }); + + await pointerAt(byTestId('hue-slider'), 'pointerdown', 0.5); + + expect(onChange).toHaveBeenCalledWith( + '#00ffff', + expect.objectContaining({ reason: 'pointer' }) + ); + expect(byTestId('hex').value).toBe('#ff0000'); + }); + + it('writes the color in the format the root names', async () => { + render(ColorPickerTest, { defaultValue: '#3366cc', format: 'hsl' }); + + expect(boundValue()).toBe('hsl(220, 60%, 50%)'); + }); + + it('carries the color in a form, and takes the first color back at a reset', async () => { + render(ColorPickerFormTest, { defaultValue: '#ff0000' }); + + const hidden = () => + byTestId('form').elements.namedItem('brand') as HTMLInputElement; + expect(hidden().value).toBe('#ff0000'); + + await userEvent.fill(byTestId('hex'), '#00ff00'); + await userEvent.keyboard('{Enter}'); + expect(hidden().value).toBe('#00ff00'); + + byTestId('reset').click(); + await new Promise((resolve) => queueMicrotask(() => resolve(null))); + await tick(); + + expect(hidden().value).toBe('#ff0000'); + }); +}); diff --git a/packages/ui/src/lib/color-picker/root/color.test.ts b/packages/ui/src/lib/color-picker/root/color.test.ts new file mode 100644 index 0000000..8004968 --- /dev/null +++ b/packages/ui/src/lib/color-picker/root/color.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +import { + colorsAreEqual, + formatColor, + getColorChannel, + getRoundedChannel, + hsbToHsl, + hsbToRgb, + hslToHsb, + parseColor, + rgbToHsb, + toCssColor, + withColorChannel +} from './color'; + +describe('color', () => { + it('reads a hex color of three, four, six and eight digits', () => { + expect(parseColor('#f00')).toEqual({ h: 0, s: 100, b: 100, a: 1 }); + expect(parseColor('#ff0000')).toEqual({ h: 0, s: 100, b: 100, a: 1 }); + expect(parseColor('#ff000080')?.a).toBeCloseTo(0.5, 1); + expect(parseColor('#f008')?.a).toBeCloseTo(0.53, 1); + }); + + it('reads rgb, hsl and hsb', () => { + expect(parseColor('rgb(255, 0, 0)')).toEqual({ h: 0, s: 100, b: 100, a: 1 }); + expect(parseColor('rgba(255, 0, 0, 0.5)')?.a).toBe(0.5); + expect(parseColor('hsl(120, 100%, 50%)')).toEqual({ h: 120, s: 100, b: 100, a: 1 }); + expect(parseColor('hsb(240, 100%, 100%)')).toEqual({ h: 240, s: 100, b: 100, a: 1 }); + }); + + it('answers null for a text that names no color', () => { + expect(parseColor('')).toBeNull(); + expect(parseColor('not a color')).toBeNull(); + expect(parseColor('#12345')).toBeNull(); + expect(parseColor('rgb(1, 2)')).toBeNull(); + }); + + it('writes the color in each format', () => { + const color = parseColor('#3366cc')!; + + expect(formatColor(color, 'hex')).toBe('#3366cc'); + expect(formatColor(color, 'rgb')).toBe('rgb(51, 102, 204)'); + expect(formatColor(color, 'hsl')).toBe('hsl(220, 60%, 50%)'); + expect(formatColor(color, 'hsb')).toBe('hsb(220, 75%, 80%)'); + }); + + it('writes the alpha only when the picker holds one', () => { + const color = { ...parseColor('#3366cc')!, a: 0.5 }; + + expect(formatColor(color, 'hex')).toBe('#3366cc'); + expect(formatColor(color, 'hex', true)).toBe('#3366cc80'); + expect(formatColor(color, 'rgb', true)).toBe('rgba(51, 102, 204, 0.5)'); + expect(toCssColor(color)).toBe('rgba(51, 102, 204, 0.5)'); + }); + + it('goes from red-green-blue to hue-saturation-brightness and back', () => { + for (const hex of ['#000000', '#ffffff', '#3366cc', '#7f4a1e', '#00ff88']) { + const color = parseColor(hex)!; + expect(formatColor(rgbToHsb(hsbToRgb(color), color.a), 'hex')).toBe(hex); + } + }); + + it('goes from brightness to lightness and back', () => { + const color = parseColor('#3366cc')!; + const hsl = hsbToHsl(color); + + expect(colorsAreEqual(hslToHsb(hsl, color.a), color)).toBe(true); + }); + + it('reads one channel of the color', () => { + const color = parseColor('#3366cc')!; + + expect(getRoundedChannel(color, 'red')).toBe(51); + expect(getRoundedChannel(color, 'green')).toBe(102); + expect(getRoundedChannel(color, 'blue')).toBe(204); + expect(getRoundedChannel(color, 'hue')).toBe(220); + expect(getRoundedChannel(color, 'saturation')).toBe(75); + expect(getRoundedChannel(color, 'brightness')).toBe(80); + expect(getRoundedChannel(color, 'lightness')).toBe(50); + expect(getRoundedChannel(color, 'alpha')).toBe(1); + }); + + it('writes one channel, and keeps the color in its limits', () => { + const color = parseColor('#3366cc')!; + + expect(formatColor(withColorChannel(color, 'red', 255), 'hex')).toBe('#ff66cc'); + expect(formatColor(withColorChannel(color, 'red', 999), 'hex')).toBe('#ff66cc'); + expect(getColorChannel(withColorChannel(color, 'hue', 10), 'hue')).toBe(10); + expect(getColorChannel(withColorChannel(color, 'alpha', 0.25), 'alpha')).toBe(0.25); + }); + + it('keeps the hue of a color that goes to black, and of a gray', () => { + const color = parseColor('#3366cc')!; + + const black = withColorChannel(withColorChannel(color, 'brightness', 0), 'red', 0); + expect(black.h).toBe(color.h); + expect(black.s).toBe(color.s); + + const gray = withColorChannel(parseColor('#808080')!, 'red', 128); + expect(Number.isFinite(gray.h)).toBe(true); + }); +}); diff --git a/packages/ui/src/lib/color-picker/root/color.ts b/packages/ui/src/lib/color-picker/root/color.ts new file mode 100644 index 0000000..e91c482 --- /dev/null +++ b/packages/ui/src/lib/color-picker/root/color.ts @@ -0,0 +1,322 @@ +/** + * The color math of the picker. + * + * The color is held as hue, saturation and brightness, with an alpha. That is the model the + * square and the hue slider move in: a square of saturation against brightness keeps its shape + * at each hue, and red-green-blue does not. Each other model is a conversion of this one. + */ + +/** The channels a slider or a field can move. */ +export type ColorChannel = + 'hue' | 'saturation' | 'brightness' | 'lightness' | 'alpha' | 'red' | 'green' | 'blue'; + +/** The text format of the value. */ +export type ColorFormat = 'hex' | 'rgb' | 'hsl' | 'hsb'; + +/** Hue from 0 to 360, saturation and brightness from 0 to 100, alpha from 0 to 1. */ +export type Color = { + h: number; + s: number; + b: number; + a: number; +}; + +export type ColorChannelRange = { + min: number; + max: number; + step: number; + /** The step of `PageUp`, `PageDown` and `Shift` with an arrow. */ + largeStep: number; +}; + +export const COLOR_CHANNEL_RANGES: Record = { + hue: { min: 0, max: 360, step: 1, largeStep: 15 }, + saturation: { min: 0, max: 100, step: 1, largeStep: 10 }, + brightness: { min: 0, max: 100, step: 1, largeStep: 10 }, + lightness: { min: 0, max: 100, step: 1, largeStep: 10 }, + alpha: { min: 0, max: 1, step: 0.01, largeStep: 0.1 }, + red: { min: 0, max: 255, step: 1, largeStep: 16 }, + green: { min: 0, max: 255, step: 1, largeStep: 16 }, + blue: { min: 0, max: 255, step: 1, largeStep: 16 } +}; + +export const BLACK: Color = { h: 0, s: 0, b: 0, a: 1 }; + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +function round(value: number, decimals = 0): number { + const factor = 10 ** decimals; + return Math.round(value * factor) / factor; +} + +/** + * The hue is a circle, thus each hue past the end comes back to the start. 360 is the one value + * that stays: it is the end of the track of a hue slider, and the color there is the color at 0. + */ +export function normalizeHue(hue: number): number { + if (!Number.isFinite(hue)) return 0; + if (hue === 360) return 360; + return ((hue % 360) + 360) % 360; +} + +export function clampColor(color: Color): Color { + return { + h: normalizeHue(color.h), + s: clamp(color.s, 0, 100), + b: clamp(color.b, 0, 100), + a: clamp(color.a, 0, 1) + }; +} + +export function colorsAreEqual(a: Color, b: Color): boolean { + return ( + round(a.h, 2) === round(b.h, 2) && + round(a.s, 2) === round(b.s, 2) && + round(a.b, 2) === round(b.b, 2) && + round(a.a, 3) === round(b.a, 3) + ); +} + +// --- Conversions ------------------------------------------------------------------------------ + +export type Rgb = { r: number; g: number; b: number }; + +export function hsbToRgb(color: Color): Rgb { + const h = (((color.h % 360) + 360) % 360) / 60; + const s = clamp(color.s, 0, 100) / 100; + const v = clamp(color.b, 0, 100) / 100; + const chroma = v * s; + const second = chroma * (1 - Math.abs((h % 2) - 1)); + const match = v - chroma; + + let rgb: [number, number, number]; + if (h < 1) rgb = [chroma, second, 0]; + else if (h < 2) rgb = [second, chroma, 0]; + else if (h < 3) rgb = [0, chroma, second]; + else if (h < 4) rgb = [0, second, chroma]; + else if (h < 5) rgb = [second, 0, chroma]; + else rgb = [chroma, 0, second]; + + return { + r: Math.round((rgb[0] + match) * 255), + g: Math.round((rgb[1] + match) * 255), + b: Math.round((rgb[2] + match) * 255) + }; +} + +export function rgbToHsb(rgb: Rgb, alpha = 1): Color { + const r = clamp(rgb.r, 0, 255) / 255; + const g = clamp(rgb.g, 0, 255) / 255; + const b = clamp(rgb.b, 0, 255) / 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const chroma = max - min; + + let hue = 0; + if (chroma !== 0) { + if (max === r) hue = ((g - b) / chroma) % 6; + else if (max === g) hue = (b - r) / chroma + 2; + else hue = (r - g) / chroma + 4; + hue *= 60; + if (hue < 0) hue += 360; + } + + return { + h: hue, + s: max === 0 ? 0 : (chroma / max) * 100, + b: max * 100, + a: clamp(alpha, 0, 1) + }; +} + +export type Hsl = { h: number; s: number; l: number }; + +export function hsbToHsl(color: Color): Hsl { + const s = clamp(color.s, 0, 100) / 100; + const v = clamp(color.b, 0, 100) / 100; + const l = v * (1 - s / 2); + const saturation = l === 0 || l === 1 ? 0 : (v - l) / Math.min(l, 1 - l); + return { h: normalizeHue(color.h), s: saturation * 100, l: l * 100 }; +} + +export function hslToHsb(hsl: Hsl, alpha = 1): Color { + const s = clamp(hsl.s, 0, 100) / 100; + const l = clamp(hsl.l, 0, 100) / 100; + const v = l + s * Math.min(l, 1 - l); + return { + h: normalizeHue(hsl.h), + s: (v === 0 ? 0 : 2 * (1 - l / v)) * 100, + b: v * 100, + a: clamp(alpha, 0, 1) + }; +} + +// --- Channels --------------------------------------------------------------------------------- + +export function getColorChannel(color: Color, channel: ColorChannel): number { + switch (channel) { + case 'hue': + return color.h; + case 'saturation': + return color.s; + case 'brightness': + return color.b; + case 'alpha': + return color.a; + case 'lightness': + return hsbToHsl(color).l; + case 'red': + return hsbToRgb(color).r; + case 'green': + return hsbToRgb(color).g; + case 'blue': + return hsbToRgb(color).b; + } +} + +/** + * The color with one channel at a new value. A color without a hue, black or a gray, keeps the + * hue and the saturation it had: they are the position of the thumb in the square, and a thumb + * that goes back to the corner on each move is a thumb the user cannot aim. + */ +export function withColorChannel(color: Color, channel: ColorChannel, value: number): Color { + const range = COLOR_CHANNEL_RANGES[channel]; + const next = clamp(value, range.min, range.max); + + switch (channel) { + case 'hue': + return { ...color, h: normalizeHue(next) }; + case 'saturation': + return { ...color, s: next }; + case 'brightness': + return { ...color, b: next }; + case 'alpha': + return { ...color, a: next }; + case 'lightness': { + const hsl = hsbToHsl(color); + return keepPosition(hslToHsb({ ...hsl, l: next }, color.a), color); + } + default: { + const rgb = hsbToRgb(color); + const key = channel === 'red' ? 'r' : channel === 'green' ? 'g' : 'b'; + return keepPosition(rgbToHsb({ ...rgb, [key]: next }, color.a), color); + } + } +} + +/** Keeps the hue, and the saturation, of a color that no longer says what they are. */ +function keepPosition(next: Color, previous: Color): Color { + const color = { ...next }; + if (color.b === 0 || color.s === 0) color.h = previous.h; + if (color.b === 0) color.s = previous.s; + return color; +} + +// --- Text ------------------------------------------------------------------------------------- + +const HEX = /^#?([\da-f]{3,8})$/i; +const FUNCTIONAL = /^(rgba?|hsla?|hsba?|hsva?)\(([^)]+)\)$/i; + +function parseNumbers(body: string): number[] { + return body + .split(/[\s,/]+/) + .map((part) => part.trim()) + .filter(Boolean) + .map((part) => (part.endsWith('%') ? Number(part.slice(0, -1)) : Number(part))); +} + +/** Reads a text into a color. It answers null for a text that names no color. */ +export function parseColor(text: string): Color | null { + const value = text.trim(); + if (!value) return null; + + const hex = HEX.exec(value); + if (hex) { + const digits = hex[1]; + if (digits.length === 3 || digits.length === 4) { + const parts = digits.split('').map((digit) => Number.parseInt(digit + digit, 16)); + return rgbToHsb( + { r: parts[0], g: parts[1], b: parts[2] }, + parts[3] === undefined ? 1 : parts[3] / 255 + ); + } + if (digits.length === 6 || digits.length === 8) { + const parts = (digits.match(/.{2}/g) ?? []).map((pair) => Number.parseInt(pair, 16)); + return rgbToHsb( + { r: parts[0], g: parts[1], b: parts[2] }, + parts[3] === undefined ? 1 : parts[3] / 255 + ); + } + return null; + } + + const functional = FUNCTIONAL.exec(value); + if (!functional) return null; + const name = functional[1].toLowerCase(); + const numbers = parseNumbers(functional[2]); + if (numbers.length < 3 || numbers.some((number) => Number.isNaN(number))) return null; + + const alpha = numbers[3] === undefined ? 1 : clamp(numbers[3], 0, 1); + if (name.startsWith('rgb')) { + return rgbToHsb({ r: numbers[0], g: numbers[1], b: numbers[2] }, alpha); + } + if (name.startsWith('hsl')) { + return hslToHsb({ h: numbers[0], s: numbers[1], l: numbers[2] }, alpha); + } + return clampColor({ h: numbers[0], s: numbers[1], b: numbers[2], a: alpha }); +} + +function toHexPair(value: number): string { + return Math.round(clamp(value, 0, 255)) + .toString(16) + .padStart(2, '0'); +} + +/** + * The text of a color. With `alpha` off, the text holds only the three color channels: a picker + * without an alpha slider must not answer a fourth number nobody can change. + */ +export function formatColor(color: Color, format: ColorFormat, alpha = false): string { + const withAlpha = alpha && color.a < 1; + + if (format === 'hex') { + const rgb = hsbToRgb(color); + const base = `#${toHexPair(rgb.r)}${toHexPair(rgb.g)}${toHexPair(rgb.b)}`; + return withAlpha ? `${base}${toHexPair(color.a * 255)}` : base; + } + + if (format === 'rgb') { + const rgb = hsbToRgb(color); + return withAlpha + ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${round(color.a, 2)})` + : `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`; + } + + if (format === 'hsl') { + const hsl = hsbToHsl(color); + const base = `${round(hsl.h)}, ${round(hsl.s)}%, ${round(hsl.l)}%`; + return withAlpha ? `hsla(${base}, ${round(color.a, 2)})` : `hsl(${base})`; + } + + const base = `${round(color.h)}, ${round(color.s)}%, ${round(color.b)}%`; + return withAlpha ? `hsba(${base}, ${round(color.a, 2)})` : `hsb(${base})`; +} + +/** The color as CSS, always with its alpha: it is what the parts paint with. */ +export function toCssColor(color: Color): string { + const rgb = hsbToRgb(color); + return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${round(color.a, 3)})`; +} + +/** The text of the hex field: six digits, or eight with an alpha. */ +export function toHexText(color: Color, alpha = false): string { + return formatColor(color, 'hex', alpha); +} + +/** The value of one channel, rounded as the field and the screen reader show it. */ +export function getRoundedChannel(color: Color, channel: ColorChannel): number { + const value = getColorChannel(color, channel); + return channel === 'alpha' ? round(value, 2) : Math.round(value); +} diff --git a/packages/ui/src/lib/color-picker/root/context.ts b/packages/ui/src/lib/color-picker/root/context.ts new file mode 100644 index 0000000..333b8e8 --- /dev/null +++ b/packages/ui/src/lib/color-picker/root/context.ts @@ -0,0 +1,182 @@ +import { getContext, setContext } from 'svelte'; +import type { Color, ColorChannel, ColorChannelRange, ColorFormat } from './color'; + +export type { Color, ColorChannel, ColorChannelRange, ColorFormat }; + +/** How a color changed. */ +export type ColorPickerChangeReason = + 'pointer' | 'keyboard' | 'input' | 'swatch' | 'eye-dropper' | 'form-reset'; + +export type ColorPickerChangeDetails = { + reason: ColorPickerChangeReason; + /** The channel that moved, when one channel moved. */ + channel?: ColorChannel; + event?: Event; +}; + +/** + * Context shared between ColorPicker.Root and its parts. + */ +export type ColorPickerContext = { + /** The instance id. Every ARIA id of the parts is made from it. */ + instanceId: string; + /** The id of the root element. */ + rootId: string; + /** The id of the `ColorPicker.Label` element, when one is in the DOM. */ + labelId: string | null; + /** The color: hue, saturation, brightness and alpha. */ + color: Color; + /** The text of the color, in the format of the root. */ + text: string; + /** The color as CSS, always with its alpha. */ + cssColor: string; + /** The color of the hue, at full saturation and brightness. */ + hueColor: string; + /** The text format of the value. */ + format: ColorFormat; + /** Whether the picker holds an alpha. */ + hasAlpha: boolean; + isDisabled: boolean; + isReadOnly: boolean; + isInvalid: boolean; + /** The `aria-label` given to the root. */ + ariaLabel: string | undefined; + /** The `aria-labelledby` given to the root. */ + ariaLabelledBy: string | undefined; + /** The `aria-describedby` given to the root. Each control carries it. */ + ariaDescribedBy: string | undefined; + /** Registers the id of `ColorPicker.Label`. Returns the unregister function. */ + registerLabel: (id: string) => () => void; + /** The id of one control of the picker, for example `area-thumb` or `channel-red`. */ + getPartId: (part: string) => string; + /** The limits and the steps of one channel. */ + getChannelRange: (channel: ColorChannel) => ColorChannelRange; + /** The value of one channel, rounded as a field shows it. */ + getChannelValue: (channel: ColorChannel) => number; + /** The position of one channel between its limits, from 0 to 100. */ + getChannelPercent: (channel: ColorChannel) => number; + /** The name of one channel in the locale, for example "Saturation". */ + getChannelLabel: (channel: ColorChannel) => string; + /** The text a screen reader reads for one channel, for example "50%". */ + getChannelValueText: (channel: ColorChannel) => string; + /** The color with one channel at another value, as CSS. It paints the track of a slider. */ + getChannelColor: (channel: ColorChannel, value: number) => string; + /** Writes one channel. Returns whether the color changed. */ + setChannel: (channel: ColorChannel, value: number, details: ColorPickerChangeDetails) => boolean; + /** Moves one channel by a step, or by the large step. */ + stepChannel: ( + channel: ColorChannel, + direction: -1 | 1, + options: { large?: boolean; event?: Event } + ) => boolean; + /** Writes the whole color. Returns whether the color changed. */ + setColor: (color: Color, details: ColorPickerChangeDetails) => boolean; + /** Reads a text into the color. Returns whether the text named a color. */ + setText: (text: string, details: ColorPickerChangeDetails) => boolean; + /** Reports the end of a sequence of changes: a key press, a drag. */ + commit: (details: ColorPickerChangeDetails) => void; +}; + +const COLOR_PICKER_KEY = Symbol('color-picker'); + +export function setColorPickerContext(ctx: ColorPickerContext) { + setContext(COLOR_PICKER_KEY, ctx); +} + +export function getColorPickerContext(): ColorPickerContext | undefined { + return getContext(COLOR_PICKER_KEY); +} + +export function useColorPickerContext(part: string): ColorPickerContext { + const ctx = getColorPickerContext(); + if (!ctx) { + throw new Error(`${part} must be used inside a ColorPicker.Root`); + } + return ctx; +} + +/** Context of one `ColorPicker.Area`, for its thumb. */ +export type ColorPickerAreaContext = { + xChannel: ColorChannel; + yChannel: ColorChannel; + /** The position of the thumb, from 0 to 100. The y axis counts from the bottom. */ + xPercent: number; + yPercent: number; + /** Whether a pointer moves the thumb. */ + isDragging: boolean; + /** Whether the area is in a right-to-left context. */ + isRtl: boolean; + areaId: string; + /** Starts a pointer drag from the thumb. The color does not change until the pointer moves. */ + startDrag: (event: PointerEvent) => void; +}; + +const COLOR_PICKER_AREA_KEY = Symbol('color-picker-area'); + +export function setColorPickerAreaContext(ctx: ColorPickerAreaContext) { + setContext(COLOR_PICKER_AREA_KEY, ctx); +} + +export function useColorPickerAreaContext(part: string): ColorPickerAreaContext { + const ctx = getContext(COLOR_PICKER_AREA_KEY); + if (!ctx) { + throw new Error(`${part} must be used inside a ColorPicker.Area`); + } + return ctx; +} + +export type ColorPickerSliderOrientation = 'horizontal' | 'vertical'; + +/** Context of one `ColorPicker.Slider`, for its thumb. */ +export type ColorPickerSliderContext = { + channel: ColorChannel; + orientation: ColorPickerSliderOrientation; + /** The position of the thumb on the track, from 0 to 100. */ + percent: number; + isDragging: boolean; + isRtl: boolean; + sliderId: string; + startDrag: (event: PointerEvent) => void; +}; + +const COLOR_PICKER_SLIDER_KEY = Symbol('color-picker-slider'); + +export function setColorPickerSliderContext(ctx: ColorPickerSliderContext) { + setContext(COLOR_PICKER_SLIDER_KEY, ctx); +} + +export function useColorPickerSliderContext(part: string): ColorPickerSliderContext { + const ctx = getContext(COLOR_PICKER_SLIDER_KEY); + if (!ctx) { + throw new Error(`${part} must be used inside a ColorPicker.Slider`); + } + return ctx; +} + +/** Context of one `ColorPicker.SwatchList`, for its swatches. */ +export type ColorPickerSwatchListContext = { + listId: string; + /** Registers a swatch. Returns its index and the unregister function. */ + register: (options: { elementRef: () => HTMLElement | null; color: () => string }) => { + index: number; + unregister: () => void; + }; + /** Whether one swatch is the one the tab order lands on. */ + isTabStop: (index: number) => boolean; + /** Moves the focus between the swatches. */ + moveFocus: (from: number, step: number | 'first' | 'last') => void; + /** Answers a press or a key on one swatch. */ + select: (color: string, event?: Event) => void; + /** Whether the color of a swatch is the color of the picker. */ + isSelected: (color: string) => boolean; +}; + +const COLOR_PICKER_SWATCH_LIST_KEY = Symbol('color-picker-swatch-list'); + +export function setColorPickerSwatchListContext(ctx: ColorPickerSwatchListContext) { + setContext(COLOR_PICKER_SWATCH_LIST_KEY, ctx); +} + +export function getColorPickerSwatchListContext(): ColorPickerSwatchListContext | undefined { + return getContext(COLOR_PICKER_SWATCH_LIST_KEY); +} diff --git a/packages/ui/src/lib/color-picker/slider-thumb/color-picker-slider-thumb.svelte b/packages/ui/src/lib/color-picker/slider-thumb/color-picker-slider-thumb.svelte new file mode 100644 index 0000000..63b459f --- /dev/null +++ b/packages/ui/src/lib/color-picker/slider-thumb/color-picker-slider-thumb.svelte @@ -0,0 +1,208 @@ + + +
+ + {#if children} + {@render (children as Snippet<[ColorPickerSliderThumbRenderState]>)(renderState)} + {/if} +
diff --git a/packages/ui/src/lib/color-picker/slider/color-picker-slider.svelte b/packages/ui/src/lib/color-picker/slider/color-picker-slider.svelte new file mode 100644 index 0000000..5e16e07 --- /dev/null +++ b/packages/ui/src/lib/color-picker/slider/color-picker-slider.svelte @@ -0,0 +1,188 @@ + + +
+ {@render children?.()} +
diff --git a/packages/ui/src/lib/color-picker/swatch-list/color-picker-swatch-list.svelte b/packages/ui/src/lib/color-picker/swatch-list/color-picker-swatch-list.svelte new file mode 100644 index 0000000..b8031b6 --- /dev/null +++ b/packages/ui/src/lib/color-picker/swatch-list/color-picker-swatch-list.svelte @@ -0,0 +1,133 @@ + + +
+ {@render children?.()} +
diff --git a/packages/ui/src/lib/color-picker/swatch/color-picker-swatch.svelte b/packages/ui/src/lib/color-picker/swatch/color-picker-swatch.svelte new file mode 100644 index 0000000..ca080a1 --- /dev/null +++ b/packages/ui/src/lib/color-picker/swatch/color-picker-swatch.svelte @@ -0,0 +1,149 @@ + + + + +
+ {@render children?.()} +
diff --git a/packages/ui/src/lib/color-picker/types.ts b/packages/ui/src/lib/color-picker/types.ts new file mode 100644 index 0000000..3c78c5d --- /dev/null +++ b/packages/ui/src/lib/color-picker/types.ts @@ -0,0 +1,230 @@ +import type { Snippet } from 'svelte'; +import type { HTMLAttributes, HTMLButtonAttributes, HTMLInputAttributes } from 'svelte/elements'; +import type { + Color, + ColorChannel, + ColorFormat, + ColorPickerChangeDetails, + ColorPickerContext, + ColorPickerSliderOrientation +} from './root/context'; + +export type { + Color, + ColorChannel, + ColorFormat, + ColorPickerChangeDetails, + ColorPickerContext, + ColorPickerSliderOrientation +}; + +export type ColorPickerRootProps = { + /** A stable id, from which the component makes its internal ARIA ids. Give one on a server. */ + id?: string; + /** + * The color, as text: a hex color, `rgb()`, `hsl()` or `hsb()`. You can bind it with + * `bind:value`. The component answers in the format of `format`. + */ + value?: string; + /** The color at the start, for when you give no `value`. The default is `#000000`. */ + defaultValue?: string; + /** + * Give your own code full control of the color. The component stops to write back to `value`, + * and it reports only through `onChange`. Thus the parent can refuse a change. + */ + controlledValue?: boolean; + /** The component calls it on each change, also on each move of a drag. */ + onChange?: (value: string, details: ColorPickerChangeDetails) => void; + /** + * The component calls it when a sequence of changes ends: at the release of a key, and at the + * end of a drag. Use it for work that must not run on each move. + */ + onChangeEnd?: (value: string, details: ColorPickerChangeDetails) => void; + /** The text format of the value. The default is `hex`. */ + format?: ColorFormat; + /** + * Keeps an alpha in the color. The value then holds a fourth number, and `ColorPicker.Slider` + * accepts the `alpha` channel. The default is off. + */ + alpha?: boolean; + /** Disables each control of the picker. The controls leave the tab order. */ + disabled?: boolean; + /** Keeps the controls in the tab order, but the color does not change. */ + readonly?: boolean; + /** Marks the color as invalid. Each control gets `aria-invalid`. */ + invalid?: boolean; + /** The name of the hidden input that carries the color in a form. */ + name?: string; + /** The id of the form that the hidden input belongs to, when the picker is outside it. */ + form?: string; + /** The accessible name of the picker, for when there is no `ColorPicker.Label`. */ + 'aria-label'?: string; + /** The id of the element that gives the picker its name, in place of `ColorPicker.Label`. */ + 'aria-labelledby'?: string; + /** The id of the element that describes the picker, for example an error message. */ + 'aria-describedby'?: string; + /** The content: the label, the area, the sliders and the fields. */ + children?: Snippet; + /** The CSS class names of the root element. */ + class?: string; + /** A bindable reference to the root element. */ + element?: HTMLDivElement | null; + /** A bindable reference to the context, for a composition of your own. */ + context?: ColorPickerContext; +} & Omit< + HTMLAttributes, + | 'class' + | 'children' + | 'id' + | 'role' + | 'aria-label' + | 'aria-labelledby' + | 'aria-describedby' + | 'onchange' +>; + +export type ColorPickerLabelProps = Omit, 'class'> & { + /** The CSS class names of the label. */ + class?: string; +}; + +export type ColorPickerAreaProps = Omit, 'class'> & { + /** The channel of the horizontal axis. The default is `saturation`. */ + xChannel?: ColorChannel; + /** The channel of the vertical axis, which counts from the bottom. The default is `brightness`. */ + yChannel?: ColorChannel; + /** The content: a `ColorPicker.AreaThumb`. */ + children?: Snippet; + /** The CSS class names of the area. */ + class?: string; + /** A bindable reference to the area element. */ + element?: HTMLDivElement | null; +}; + +export type ColorPickerAreaThumbRenderState = { + /** The position of the thumb on the horizontal axis, from 0 to 100. */ + xPercent: number; + /** The position of the thumb on the vertical axis, from 0 to 100, from the bottom. */ + yPercent: number; + /** Whether a pointer moves the thumb. */ + dragging: boolean; + /** Whether one of the two inputs of the thumb has the focus. */ + focused: boolean; +}; + +export type ColorPickerAreaThumbProps = Omit< + HTMLAttributes, + 'class' | 'children' +> & { + /** + * The content. As a snippet with one argument, it receives the render state: `xPercent`, + * `yPercent`, `dragging` and `focused`. + */ + children?: Snippet<[ColorPickerAreaThumbRenderState]> | Snippet; + /** The CSS class names of the thumb. */ + class?: string; + /** A bindable reference to the thumb element. */ + element?: HTMLDivElement | null; +}; + +export type ColorPickerSliderProps = Omit, 'class'> & { + /** The channel the slider moves, for example `hue` or `alpha`. */ + channel: ColorChannel; + /** The direction of the track. The default is horizontal. */ + orientation?: ColorPickerSliderOrientation; + /** The content: a `ColorPicker.SliderThumb`. */ + children?: Snippet; + /** The CSS class names of the track. */ + class?: string; + /** A bindable reference to the track element. */ + element?: HTMLDivElement | null; +}; + +export type ColorPickerSliderThumbRenderState = { + /** The position of the thumb on the track, from 0 to 100. */ + percent: number; + /** The value of the channel. */ + value: number; + /** Whether a pointer moves the thumb. */ + dragging: boolean; + /** Whether the input of the thumb has the focus. */ + focused: boolean; +}; + +export type ColorPickerSliderThumbProps = Omit< + HTMLAttributes, + 'class' | 'children' +> & { + /** + * The content. As a snippet with one argument, it receives the render state: `percent`, + * `value`, `dragging` and `focused`. + */ + children?: Snippet<[ColorPickerSliderThumbRenderState]> | Snippet; + /** The CSS class names of the thumb. */ + class?: string; + /** A bindable reference to the thumb element. */ + element?: HTMLDivElement | null; +}; + +export type ColorPickerHexFieldProps = Omit< + HTMLInputAttributes, + 'class' | 'children' | 'value' | 'type' +> & { + /** The CSS class names of the field. */ + class?: string; + /** A bindable reference to the input element. */ + element?: HTMLInputElement | null; +}; + +export type ColorPickerChannelFieldProps = Omit< + HTMLInputAttributes, + 'class' | 'children' | 'value' | 'type' | 'min' | 'max' | 'step' +> & { + /** The channel the field holds, for example `red`. */ + channel: ColorChannel; + /** The CSS class names of the field. */ + class?: string; + /** A bindable reference to the input element. */ + element?: HTMLInputElement | null; +}; + +export type ColorPickerSwatchListProps = Omit, 'class'> & { + /** The content: one `ColorPicker.Swatch` for each color. */ + children?: Snippet; + /** The CSS class names of the list. */ + class?: string; + /** A bindable reference to the list element. */ + element?: HTMLDivElement | null; +}; + +export type ColorPickerSwatchProps = Omit< + HTMLAttributes, + 'class' | 'children' | 'aria-label' +> & { + /** The color of the swatch, as text. */ + color: string; + /** The name of the swatch, for the screen reader. The default is the text of its color. */ + 'aria-label'?: string; + /** The content. Without it, the swatch is an empty element that your CSS paints. */ + children?: Snippet; + /** The CSS class names of the swatch. */ + class?: string; + /** A bindable reference to the swatch element. */ + element?: HTMLDivElement | null; +}; + +export type ColorPickerPreviewProps = Omit, 'class'> & { + /** The content. Without it, the preview is an empty element that your CSS paints. */ + children?: Snippet; + /** The CSS class names of the preview. */ + class?: string; + /** A bindable reference to the preview element. */ + element?: HTMLDivElement | null; +}; + +export type ColorPickerEyeDropperProps = Omit & { + /** The CSS class names of the button. */ + class?: string; + /** A bindable reference to the button element. */ + element?: HTMLButtonElement | null; +}; diff --git a/packages/ui/src/lib/index.ts b/packages/ui/src/lib/index.ts index b1c62ab..14a2f59 100644 --- a/packages/ui/src/lib/index.ts +++ b/packages/ui/src/lib/index.ts @@ -9,6 +9,7 @@ export { Button } from './button/index.js'; export { Checkbox } from './checkbox/index.js'; export { CheckboxGroup } from './checkbox-group/index.js'; export { Collapsible } from './collapsible/index.js'; +export { ColorPicker } from './color-picker/index.js'; export { ComboBox } from './combobox/index.js'; export { Calendar } from './calendar/index.js'; export { Clock } from './clock/index.js'; @@ -20,9 +21,11 @@ export { Drawer } from './drawer/index.js'; export { ListBox } from './listbox/index.js'; export { Menu } from './menu/index.js'; export { NumberField } from './numberfield/index.js'; +export { PinInput } from './pin-input/index.js'; export { Popover } from './popover/index.js'; export { Progress } from './progress/index.js'; export { RadioGroup } from './radio-group/index.js'; +export { Rating } from './rating/index.js'; export { Select } from './select/index.js'; export { Slider } from './slider/index.js'; export { Switch } from './switch/index.js'; @@ -57,6 +60,7 @@ export * from './checkbox-group/index.js'; export * from './avatar/index.js'; export * from './breadcrumbs/index.js'; export * from './collapsible/index.js'; +export * from './color-picker/index.js'; export * from './combobox/index.js'; export * from './calendar/index.js'; export * from './clock/index.js'; @@ -69,9 +73,11 @@ export * from './drawer/index.js'; export * from './listbox/index.js'; export * from './menu/index.js'; export * from './numberfield/index.js'; +export * from './pin-input/index.js'; export * from './popover/index.js'; export * from './progress/index.js'; export * from './radio-group/index.js'; +export * from './rating/index.js'; export * from './select/index.js'; export * from './slider/index.js'; export * from './switch/index.js'; diff --git a/packages/ui/src/lib/internal/localized-strings.ts b/packages/ui/src/lib/internal/localized-strings.ts index 2b37d4d..37873aa 100644 --- a/packages/ui/src/lib/internal/localized-strings.ts +++ b/packages/ui/src/lib/internal/localized-strings.ts @@ -393,6 +393,150 @@ const LOCALIZED_STRINGS = { fr: '{count} notifications', de: '{count} Benachrichtigungen', it: '{count} notifiche' + }, + 'rating.valueText': { + en: '{value} of {count}', + es: '{value} de {count}', + pt: '{value} de {count}', + fr: '{value} sur {count}', + de: '{value} von {count}', + it: '{value} su {count}' + }, + 'rating.empty': { + en: 'No rating', + es: 'Sin calificación', + pt: 'Sem classificação', + fr: 'Aucune note', + de: 'Keine Bewertung', + it: 'Nessuna valutazione' + }, + 'rating.label': { + en: 'Rating', + es: 'Calificación', + pt: 'Classificação', + fr: 'Note', + de: 'Bewertung', + it: 'Valutazione' + }, + 'pinInput.digitLabel': { + en: 'Digit {index} of {count}', + es: 'Dígito {index} de {count}', + pt: 'Dígito {index} de {count}', + fr: 'Chiffre {index} sur {count}', + de: 'Ziffer {index} von {count}', + it: 'Cifra {index} di {count}' + }, + 'pinInput.characterLabel': { + en: 'Character {index} of {count}', + es: 'Carácter {index} de {count}', + pt: 'Caractere {index} de {count}', + fr: 'Caractère {index} sur {count}', + de: 'Zeichen {index} von {count}', + it: 'Carattere {index} di {count}' + }, + 'pinInput.label': { + en: 'Verification code', + es: 'Código de verificación', + pt: 'Código de verificação', + fr: 'Code de vérification', + de: 'Bestätigungscode', + it: 'Codice di verifica' + }, + 'colorPicker.label': { + en: 'Color', + es: 'Color', + pt: 'Cor', + fr: 'Couleur', + de: 'Farbe', + it: 'Colore' + }, + 'colorPicker.hue': { + en: 'Hue', + es: 'Matiz', + pt: 'Matiz', + fr: 'Teinte', + de: 'Farbton', + it: 'Tonalità' + }, + 'colorPicker.saturation': { + en: 'Saturation', + es: 'Saturación', + pt: 'Saturação', + fr: 'Saturation', + de: 'Sättigung', + it: 'Saturazione' + }, + 'colorPicker.brightness': { + en: 'Brightness', + es: 'Brillo', + pt: 'Brilho', + fr: 'Luminosité', + de: 'Helligkeit', + it: 'Luminosità' + }, + 'colorPicker.lightness': { + en: 'Lightness', + es: 'Luminosidad', + pt: 'Luminosidade', + fr: 'Clarté', + de: 'Helligkeit', + it: 'Chiarezza' + }, + 'colorPicker.alpha': { + en: 'Alpha', + es: 'Alfa', + pt: 'Alfa', + fr: 'Alpha', + de: 'Alpha', + it: 'Alfa' + }, + 'colorPicker.red': { + en: 'Red', + es: 'Rojo', + pt: 'Vermelho', + fr: 'Rouge', + de: 'Rot', + it: 'Rosso' + }, + 'colorPicker.green': { + en: 'Green', + es: 'Verde', + pt: 'Verde', + fr: 'Vert', + de: 'Grün', + it: 'Verde' + }, + 'colorPicker.blue': { + en: 'Blue', + es: 'Azul', + pt: 'Azul', + fr: 'Bleu', + de: 'Blau', + it: 'Blu' + }, + 'colorPicker.hex': { + en: 'Hex', + es: 'Hex', + pt: 'Hex', + fr: 'Hex', + de: 'Hex', + it: 'Hex' + }, + 'colorPicker.eyeDropper': { + en: 'Pick a color from the screen', + es: 'Tomar un color de la pantalla', + pt: 'Escolher uma cor da tela', + fr: 'Prendre une couleur sur l’écran', + de: 'Eine Farbe vom Bildschirm wählen', + it: 'Scegliere un colore dallo schermo' + }, + 'colorPicker.swatches': { + en: 'Color swatches', + es: 'Muestras de color', + pt: 'Amostras de cor', + fr: 'Échantillons de couleur', + de: 'Farbfelder', + it: 'Campioni di colore' } } as const satisfies Record; diff --git a/packages/ui/src/lib/pin-input/README.md b/packages/ui/src/lib/pin-input/README.md new file mode 100644 index 0000000..1a12a42 --- /dev/null +++ b/packages/ui/src/lib/pin-input/README.md @@ -0,0 +1,55 @@ +# PinInput + +## Description + +`PinInput` is a form field for a short code: a PIN, or the verification code of a message. Each character has its own cell, and each cell is a real input. The telephone shows the correct keyboard, and a code from a message fills each cell with one touch. + +## Anatomy + +- `PinInput.Root` +- `PinInput.Label` +- `PinInput.Cell` + +```svelte + + Verification code + {#each { length: 6 } as _, index (index)} + + {/each} + +``` + +## Usage guidelines + +- Use `bind:value` for the state, or `value` with `onChange` and `controlledValue` to hold it yourself. +- Give one `PinInput.Cell` for each character of `length`. +- Use `onComplete` for the work that follows the last character, for example the send of the form. +- Use `otp` for a code that arrives in a message, and `mask` for a PIN the shoulder of a stranger must not read. +- `type` decides which characters each cell accepts: `numeric`, `alphanumeric` or `alphabetic`. Give `pattern` for a test of your own. +- The value has no holes. The characters fill the cells from the first one. A press on a cell past the first empty one goes to that one. A character that goes away takes the ones after it one cell to the left. + +## API reference + +- `PinInput.Root` + - `value?: string` + - `defaultValue?: string` + - `controlledValue?: boolean` + - `onChange?: (value, details) => void` + - `onComplete?: (value) => void` + - `length?: number` + - `type?: 'numeric' | 'alphanumeric' | 'alphabetic'`, `pattern?: RegExp` + - `otp?: boolean`, `mask?: boolean`, `placeholder?: string` + - `blurOnComplete?: boolean` + - `disabled?: boolean`, `readonly?: boolean`, `required?: boolean`, `invalid?: boolean` + - `name?: string`, `form?: string` +- `PinInput.Cell` + - `index?: number` + - `aria-label?: string` + +## Accessibility + +- The root is a `role="group"` named by `PinInput.Label`, `aria-labelledby` or `aria-label`. With `otp` the group takes the name "Verification code" in the locale. +- Each cell is an `` with its own name, for example "Digit 2 of 6", and with `inputmode`, `autocomplete` and `aria-invalid`. +- The keyboard: a character moves the focus to the next cell. `Backspace` clears the cell, or the one before it. `Delete` clears the cell, the arrows move between the cells, and `Home` and `End` go to the ends. The horizontal arrows follow the text direction. +- A paste goes across the cells from the cell it starts in, and the characters the type refuses are dropped. +- The whole value in a form comes from a hidden input. A `
` reset takes the first value back. diff --git a/packages/ui/src/lib/pin-input/TODO.md b/packages/ui/src/lib/pin-input/TODO.md new file mode 100644 index 0000000..426a348 --- /dev/null +++ b/packages/ui/src/lib/pin-input/TODO.md @@ -0,0 +1,15 @@ +# PinInput TODO + +## Goal + +Track PinInput work with a single mandatory TODO format. + +## Backlog + +- [x] [S][P0][Area: Architecture][Owner: Unassigned][Target: Done] Create the `Root`, `Label` and `Cell` parts with namespace exports. +- [x] [S][P0][Area: Accessibility][Owner: Unassigned][Target: Done] Make each cell a real input with its own name, its inputmode and its autocomplete. +- [x] [S][P0][Area: Keyboard][Owner: Unassigned][Target: Done] Handle Backspace, Delete, the arrows, Home and End, with the text direction. +- [x] [S][P0][Area: Input][Owner: Unassigned][Target: Done] Spread a paste, and a code that a message fills into one cell, across the cells. +- [x] [S][P0][Area: Forms][Owner: Unassigned][Target: Done] Send the whole value with a hidden input, and return to the default on a form reset. +- [ ] [S][P2][Area: API][Owner: Unassigned][Target: TBD] Add a separator part between two groups of cells, for a code that reads in two halves. +- [ ] [C][P3][Area: Input][Owner: Unassigned][Target: TBD] Read the code of a message with the Web OTP API, where the browser has it. diff --git a/packages/ui/src/lib/pin-input/cell/pin-input-cell.svelte b/packages/ui/src/lib/pin-input/cell/pin-input-cell.svelte new file mode 100644 index 0000000..6e6b7ba --- /dev/null +++ b/packages/ui/src/lib/pin-input/cell/pin-input-cell.svelte @@ -0,0 +1,210 @@ + + + diff --git a/packages/ui/src/lib/pin-input/index.parts.ts b/packages/ui/src/lib/pin-input/index.parts.ts new file mode 100644 index 0000000..33e7c24 --- /dev/null +++ b/packages/ui/src/lib/pin-input/index.parts.ts @@ -0,0 +1,3 @@ +export { default as Root } from './root/pin-input-root.svelte'; +export { default as Label } from './label/pin-input-label.svelte'; +export { default as Cell } from './cell/pin-input-cell.svelte'; diff --git a/packages/ui/src/lib/pin-input/index.ts b/packages/ui/src/lib/pin-input/index.ts new file mode 100644 index 0000000..5ee8575 --- /dev/null +++ b/packages/ui/src/lib/pin-input/index.ts @@ -0,0 +1,21 @@ +export * as PinInput from './index.parts.js'; + +export { default as PinInputRoot } from './root/pin-input-root.svelte'; +export { default as PinInputLabel } from './label/pin-input-label.svelte'; +export { default as PinInputCell } from './cell/pin-input-cell.svelte'; + +export type { + PinInputRootProps, + PinInputLabelProps, + PinInputCellProps, + PinInputType +} from './types.js'; + +export { + getPinInputContext, + setPinInputContext, + usePinInputContext, + type PinInputChangeDetails, + type PinInputChangeReason, + type PinInputContext +} from './root/context.js'; diff --git a/packages/ui/src/lib/pin-input/label/pin-input-label.svelte b/packages/ui/src/lib/pin-input/label/pin-input-label.svelte new file mode 100644 index 0000000..f2dc9e6 --- /dev/null +++ b/packages/ui/src/lib/pin-input/label/pin-input-label.svelte @@ -0,0 +1,43 @@ + + + + {@render children?.()} + diff --git a/packages/ui/src/lib/pin-input/root/context.ts b/packages/ui/src/lib/pin-input/root/context.ts new file mode 100644 index 0000000..d29eb9b --- /dev/null +++ b/packages/ui/src/lib/pin-input/root/context.ts @@ -0,0 +1,87 @@ +import { getContext, setContext } from 'svelte'; +import type { PinInputType } from './pin-input-utils'; + +export type { PinInputType }; + +/** How a value changed. */ +export type PinInputChangeReason = 'input' | 'paste' | 'delete' | 'clear' | 'form-reset'; + +export type PinInputChangeDetails = { + reason: PinInputChangeReason; + event?: Event; +}; + +/** + * Context shared between PinInput.Root and its parts. + */ +export type PinInputContext = { + /** The instance id. Every ARIA id of the parts is made from it. */ + instanceId: string; + /** The id of the root element. */ + rootId: string; + /** The id of the `PinInput.Label` element, when one is in the DOM. */ + labelId: string | null; + /** The value. It is never longer than `length`, and it has no holes. */ + value: string; + /** The count of cells. */ + length: number; + /** Which characters each cell accepts. */ + type: PinInputType; + /** Whether the cells hide the characters. */ + mask: boolean; + /** Whether the cells accept a code from a message. */ + otp: boolean; + /** The text of an empty cell. */ + placeholder: string | undefined; + isDisabled: boolean; + isReadOnly: boolean; + isRequired: boolean; + isInvalid: boolean; + /** The index of the cell that has the focus, or null. */ + focusedIndex: number | null; + /** Whether the focus in the group must show as keyboard focus. */ + isFocusVisible: boolean; + /** The `aria-describedby` given to the root. Each cell carries it. */ + ariaDescribedBy: string | undefined; + /** Registers the id of `PinInput.Label`. Returns the unregister function. */ + registerLabel: (id: string) => () => void; + /** Registers a cell. Without an index, the cell takes the next free one, in mount order. */ + registerCell: (options: { index?: number; inputRef: () => HTMLInputElement | null }) => { + index: number; + unregister: () => void; + }; + /** The id of one cell. */ + getCellId: (index: number) => string; + /** The character of one cell, or an empty text. */ + getCellCharacter: (index: number) => string; + /** The accessible name of one cell, for example "Digit 2 of 6". */ + getCellLabel: (index: number) => string; + /** Writes text at one cell. The focus goes to the cell after the last character it wrote. */ + insertText: (index: number, text: string, details: PinInputChangeDetails) => void; + /** Removes the character of one cell. The characters after it move to the left. */ + deleteCharacter: (index: number, details: PinInputChangeDetails) => void; + /** Empties the value and puts the focus on the first cell. */ + clear: (event?: Event) => void; + /** Puts the focus on one cell. The index is clamped to the first empty cell. */ + focusCell: (index: number, modality?: 'keyboard' | 'pointer') => void; + setCellFocus: (index: number, focused: boolean) => void; + setFocusVisible: (visible: boolean) => void; +}; + +const PIN_INPUT_KEY = Symbol('pin-input'); + +export function setPinInputContext(ctx: PinInputContext) { + setContext(PIN_INPUT_KEY, ctx); +} + +export function getPinInputContext(): PinInputContext | undefined { + return getContext(PIN_INPUT_KEY); +} + +export function usePinInputContext(part: string): PinInputContext { + const ctx = getPinInputContext(); + if (!ctx) { + throw new Error(`${part} must be used inside a PinInput.Root`); + } + return ctx; +} diff --git a/packages/ui/src/lib/pin-input/root/pin-input-form-test.svelte b/packages/ui/src/lib/pin-input/root/pin-input-form-test.svelte new file mode 100644 index 0000000..b802e45 --- /dev/null +++ b/packages/ui/src/lib/pin-input/root/pin-input-form-test.svelte @@ -0,0 +1,20 @@ + + + + + {#each { length } as _, index (index)} + + {/each} + + + diff --git a/packages/ui/src/lib/pin-input/root/pin-input-root.svelte b/packages/ui/src/lib/pin-input/root/pin-input-root.svelte new file mode 100644 index 0000000..c9a9730 --- /dev/null +++ b/packages/ui/src/lib/pin-input/root/pin-input-root.svelte @@ -0,0 +1,337 @@ + + +
+ {#if name} + + {/if} + {@render children?.()} +
diff --git a/packages/ui/src/lib/pin-input/root/pin-input-ssr.test.ts b/packages/ui/src/lib/pin-input/root/pin-input-ssr.test.ts new file mode 100644 index 0000000..3047442 --- /dev/null +++ b/packages/ui/src/lib/pin-input/root/pin-input-ssr.test.ts @@ -0,0 +1,43 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest'; +import { render } from 'svelte/server'; +import PinInputTest from './pin-input-test.svelte'; + +function getTag(source: string, marker: string) { + const markerIndex = source.indexOf(marker); + if (markerIndex === -1) return ''; + const tagStart = source.lastIndexOf('<', markerIndex); + const tagEnd = source.indexOf('>', markerIndex); + if (tagStart === -1 || tagEnd === -1) return ''; + return source.slice(tagStart, tagEnd + 1); +} + +describe('PinInput SSR', () => { + it('renders the group, the characters and the names before hydration', () => { + const { body } = render(PinInputTest, { props: { defaultValue: '12' } }); + + expect(getTag(body, 'data-testid="root"')).toContain('role="group"'); + expect(getTag(body, 'data-testid="cell-0"')).toContain('value="1"'); + expect(getTag(body, 'data-testid="cell-0"')).toContain('aria-label="Digit 1 of 4"'); + expect(getTag(body, 'data-testid="cell-1"')).toContain('data-filled="true"'); + expect(getTag(body, 'data-testid="cell-2"')).toContain('data-active="true"'); + }); + + it('renders the code of a message with its own name and autocomplete', () => { + const { body } = render(PinInputTest, { props: { otp: true, withLabel: false } }); + + expect(getTag(body, 'data-testid="root"')).toContain('aria-label="Verification code"'); + expect(getTag(body, 'data-testid="cell-0"')).toContain('autocomplete="one-time-code"'); + }); + + it('reports nothing while rendering', () => { + const changes: unknown[] = []; + + render(PinInputTest, { + props: { defaultValue: '12', onChange: (value: unknown) => changes.push(value) } + }); + + expect(changes).toEqual([]); + }); +}); diff --git a/packages/ui/src/lib/pin-input/root/pin-input-test.svelte b/packages/ui/src/lib/pin-input/root/pin-input-test.svelte new file mode 100644 index 0000000..eeb6925 --- /dev/null +++ b/packages/ui/src/lib/pin-input/root/pin-input-test.svelte @@ -0,0 +1,84 @@ + + +
+ + + {#if withLabel} + Code + {/if} + {#each { length } as _, index (index)} + + {/each} + + + {JSON.stringify(value)} +
diff --git a/packages/ui/src/lib/pin-input/root/pin-input-utils.test.ts b/packages/ui/src/lib/pin-input/root/pin-input-utils.test.ts new file mode 100644 index 0000000..a7a0f6a --- /dev/null +++ b/packages/ui/src/lib/pin-input/root/pin-input-utils.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { + clampPinInputIndex, + deletePinInputCharacter, + filterPinInputText, + getPinInputPattern, + insertPinInputText +} from './pin-input-utils'; + +describe('pin input utils', () => { + it('keeps only the characters the type accepts', () => { + expect(filterPinInputText('1a2b3', getPinInputPattern('numeric'))).toBe('123'); + expect(filterPinInputText('1a2b3', getPinInputPattern('alphabetic'))).toBe('ab'); + expect(filterPinInputText('1a-2b', getPinInputPattern('alphanumeric'))).toBe('1a2b'); + }); + + it('takes a test of the consumer in place of the one of the type', () => { + expect(filterPinInputText('abcd', getPinInputPattern('numeric', /[ab]/))).toBe('ab'); + }); + + it('keeps a global test from holding its position between two characters', () => { + expect(filterPinInputText('121', getPinInputPattern('numeric', /1/g))).toBe('11'); + }); + + it('writes text at one position, over the characters that are there', () => { + expect(insertPinInputText('', 0, '12', 4)).toBe('12'); + expect(insertPinInputText('1234', 1, '9', 4)).toBe('1934'); + expect(insertPinInputText('9', 1, '12345', 4)).toBe('9123'); + }); + + it('writes at the end when the position is past it', () => { + expect(insertPinInputText('1', 3, '2', 4)).toBe('12'); + }); + + it('moves the characters after the one it removes to the left', () => { + expect(deletePinInputCharacter('1234', 0)).toBe('234'); + expect(deletePinInputCharacter('1234', 3)).toBe('123'); + expect(deletePinInputCharacter('12', 5)).toBe('12'); + }); + + it('clamps the focus to the first cell that is open', () => { + expect(clampPinInputIndex(3, '1', 4)).toBe(1); + expect(clampPinInputIndex(-1, '1', 4)).toBe(0); + expect(clampPinInputIndex(9, '1234', 4)).toBe(3); + }); +}); diff --git a/packages/ui/src/lib/pin-input/root/pin-input-utils.ts b/packages/ui/src/lib/pin-input/root/pin-input-utils.ts new file mode 100644 index 0000000..d361558 --- /dev/null +++ b/packages/ui/src/lib/pin-input/root/pin-input-utils.ts @@ -0,0 +1,55 @@ +export type PinInputType = 'numeric' | 'alphanumeric' | 'alphabetic'; + +const PATTERNS: Record = { + numeric: /[0-9]/, + alphanumeric: /[a-zA-Z0-9]/, + alphabetic: /[a-zA-Z]/ +}; + +/** The test one character must pass. A pattern of the consumer replaces the one of the type. */ +export function getPinInputPattern(type: PinInputType, pattern?: RegExp): RegExp { + return pattern ?? PATTERNS[type]; +} + +/** The characters of a text that pass the test, in order. */ +export function filterPinInputText(text: string, pattern: RegExp): string { + let result = ''; + for (const character of text) { + // A global pattern holds `lastIndex` between two tests, thus a fresh one each time. + if (new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, '')).test(character)) { + result += character; + } + } + return result; +} + +/** + * Writes text into the value at one position. The value has no holes: a write past the end goes + * to the end, and the text after the position moves to the right. The result is not longer than + * the length. + */ +export function insertPinInputText( + value: string, + index: number, + text: string, + length: number +): string { + const at = Math.min(Math.max(index, 0), value.length); + const next = value.slice(0, at) + text + value.slice(at + text.length); + return next.slice(0, length); +} + +/** Removes one character. The characters after it move to the left. */ +export function deletePinInputCharacter(value: string, index: number): string { + if (index < 0 || index >= value.length) return value; + return value.slice(0, index) + value.slice(index + 1); +} + +/** + * The cell the focus goes to. The value has no holes, thus the first empty cell is the one after + * the last character. A full value keeps the focus on the last cell. + */ +export function clampPinInputIndex(index: number, value: string, length: number): number { + const lastOpen = Math.min(value.length, length - 1); + return Math.min(Math.max(index, 0), lastOpen); +} diff --git a/packages/ui/src/lib/pin-input/root/pin-input.test.ts b/packages/ui/src/lib/pin-input/root/pin-input.test.ts new file mode 100644 index 0000000..7c296cb --- /dev/null +++ b/packages/ui/src/lib/pin-input/root/pin-input.test.ts @@ -0,0 +1,285 @@ +import { tick } from 'svelte'; +import { describe, expect, it, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import { userEvent } from 'vitest/browser'; +import { expectNoFalseFocusAttributes } from '../../test-utils/focus-contract'; +import PinInputFormTest from './pin-input-form-test.svelte'; +import PinInputTest from './pin-input-test.svelte'; + +function byTestId(id: string): T { + const element = document.querySelector(`[data-testid="${id}"]`); + if (!element) throw new Error(`No element with data-testid="${id}"`); + return element; +} + +function cell(index: number): HTMLInputElement { + return byTestId(`cell-${index}`); +} + +function boundValue(): string | null { + return JSON.parse(byTestId('bound-value').textContent ?? 'null'); +} + +/** Pastes text into the cell that has the focus. */ +async function paste(target: HTMLElement, text: string) { + const data = new DataTransfer(); + data.setData('text', text); + target.dispatchEvent( + new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }) + ); + await tick(); +} + +describe('PinInput', () => { + it('renders a group named by the label, with one input per cell', async () => { + const screen = render(PinInputTest, { defaultValue: '12' }); + + const group = screen.getByRole('group', { name: 'Code' }); + expect(group.element()).toBe(byTestId('root')); + + expect(cell(0).value).toBe('1'); + expect(cell(1).value).toBe('2'); + expect(cell(2).value).toBe(''); + expect(cell(0).getAttribute('aria-label')).toBe('Digit 1 of 4'); + expect(cell(0).inputMode).toBe('numeric'); + expect(cell(0).type).toBe('text'); + expect(cell(1).getAttribute('data-filled')).toBe('true'); + expect(cell(2).getAttribute('data-active')).toBe('true'); + }); + + it('writes one character per cell, and moves the focus to the next one', async () => { + const changes: string[] = []; + render(PinInputTest, { onChange: (value) => changes.push(value) }); + + await userEvent.click(cell(0)); + await userEvent.keyboard('1'); + + expect(boundValue()).toBe('1'); + expect(document.activeElement).toBe(cell(1)); + + await userEvent.keyboard('2'); + + expect(boundValue()).toBe('12'); + expect(document.activeElement).toBe(cell(2)); + expect(changes).toEqual(['1', '12']); + expectNoFalseFocusAttributes(); + }); + + it('refuses a character the type does not accept', async () => { + render(PinInputTest, { type: 'numeric' }); + + await userEvent.click(cell(0)); + await userEvent.keyboard('a'); + + expect(boundValue()).toBe(''); + expect(cell(0).value).toBe(''); + expect(document.activeElement).toBe(cell(0)); + }); + + it('takes the letters and the digits of an alphanumeric code', async () => { + render(PinInputTest, { type: 'alphanumeric' }); + + await userEvent.click(cell(0)); + await userEvent.keyboard('a1'); + + expect(boundValue()).toBe('a1'); + expect(cell(0).inputMode).toBe('text'); + }); + + it('takes a test of the consumer in place of the one of the type', async () => { + render(PinInputTest, { type: 'alphanumeric', pattern: /[xyz]/ }); + + await userEvent.click(cell(0)); + await userEvent.keyboard('ax'); + + expect(boundValue()).toBe('x'); + }); + + it('writes over the character of a cell that has one', async () => { + render(PinInputTest, { defaultValue: '1234' }); + + await userEvent.click(cell(1)); + await userEvent.keyboard('9'); + + expect(boundValue()).toBe('1934'); + }); + + it('reports the value one time when the last cell takes a character', async () => { + const onComplete = vi.fn(); + render(PinInputTest, { defaultValue: '123', onComplete }); + + await userEvent.click(cell(3)); + await userEvent.keyboard('4'); + + expect(onComplete).toHaveBeenCalledTimes(1); + expect(onComplete).toHaveBeenCalledWith('1234'); + expect(byTestId('root').getAttribute('data-complete')).toBe('true'); + }); + + it('moves the focus off the last cell at the end, when the consumer asks for it', async () => { + render(PinInputTest, { defaultValue: '123', blurOnComplete: true }); + + await userEvent.click(cell(3)); + await userEvent.keyboard('4'); + + expect(document.activeElement).not.toBe(cell(3)); + }); + + it('spreads a code that arrives in one cell across the cells', async () => { + render(PinInputTest); + + await userEvent.click(cell(0)); + await paste(cell(0), '4321'); + + expect(boundValue()).toBe('4321'); + expect(cell(3).value).toBe('1'); + expect(document.activeElement).toBe(cell(3)); + }); + + it('spreads a paste from the cell it starts in, and drops what is past the last cell', async () => { + render(PinInputTest, { defaultValue: '9' }); + + await userEvent.click(cell(1)); + await paste(cell(1), '12345'); + + expect(boundValue()).toBe('9123'); + }); + + it('drops the characters of a paste that the type refuses', async () => { + render(PinInputTest); + + await userEvent.click(cell(0)); + await paste(cell(0), '1a2b3'); + + expect(boundValue()).toBe('123'); + }); + + it('clears the cell with Backspace, and the one before it when the cell is empty', async () => { + render(PinInputTest, { defaultValue: '12' }); + + await userEvent.click(cell(1)); + await userEvent.keyboard('{Backspace}'); + + expect(boundValue()).toBe('1'); + expect(document.activeElement).toBe(cell(1)); + + await userEvent.keyboard('{Backspace}'); + + expect(boundValue()).toBe(''); + expect(document.activeElement).toBe(cell(0)); + }); + + it('moves the characters after the one it removes to the left', async () => { + render(PinInputTest, { defaultValue: '1234' }); + + await userEvent.click(cell(0)); + await userEvent.keyboard('{Delete}'); + + expect(boundValue()).toBe('234'); + expect(cell(0).value).toBe('2'); + }); + + it('moves the focus with the arrows, with Home and with End', async () => { + render(PinInputTest, { defaultValue: '1234' }); + + await userEvent.click(cell(2)); + await userEvent.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(cell(1)); + + await userEvent.keyboard('{ArrowRight}{ArrowRight}'); + expect(document.activeElement).toBe(cell(3)); + + await userEvent.keyboard('{Home}'); + expect(document.activeElement).toBe(cell(0)); + + await userEvent.keyboard('{End}'); + expect(document.activeElement).toBe(cell(3)); + }); + + it('mirrors the arrows in a right-to-left context', async () => { + render(PinInputTest, { defaultValue: '1234', dir: 'rtl' }); + + await userEvent.click(cell(2)); + await userEvent.keyboard('{ArrowLeft}'); + + expect(document.activeElement).toBe(cell(3)); + }); + + it('sends the focus to the first cell that is open', async () => { + render(PinInputTest, { defaultValue: '1' }); + + await userEvent.click(cell(3)); + + expect(document.activeElement).toBe(cell(1)); + }); + + it('hides the characters when the consumer asks for it', async () => { + render(PinInputTest, { defaultValue: '12', mask: true }); + + expect(cell(0).type).toBe('password'); + }); + + it('names the cells for a code that arrives in a message', async () => { + const screen = render(PinInputTest, { otp: true, withLabel: false }); + + expect(screen.getByRole('group', { name: 'Verification code' }).element()).toBe( + byTestId('root') + ); + expect(cell(0).autocomplete).toBe('one-time-code'); + }); + + it('changes nothing while disabled, and leaves the cells out of the tab order', async () => { + render(PinInputTest, { defaultValue: '1', disabled: true }); + + await userEvent.keyboard('{Tab}{Tab}'); + expect(document.activeElement).toBe(byTestId('after')); + expect(cell(0).disabled).toBe(true); + }); + + it('keeps the cells in the tab order while readonly, and changes nothing', async () => { + render(PinInputTest, { defaultValue: '12', readonly: true }); + + await userEvent.click(cell(1)); + await userEvent.keyboard('9'); + + expect(boundValue()).toBe('12'); + expect(cell(1).readOnly).toBe(true); + }); + + it('refuses the change in controlled mode until the parent sends the value', async () => { + const onChange = vi.fn(); + render(PinInputTest, { value: '12', controlledValue: true, onChange }); + + await userEvent.click(cell(2)); + await userEvent.keyboard('3'); + + expect(onChange).toHaveBeenCalledWith('123', expect.objectContaining({ reason: 'input' })); + expect(cell(2).value).toBe(''); + }); + + it('marks each cell as invalid and as necessary', async () => { + render(PinInputTest, { invalid: true, required: true }); + + expect(cell(0).getAttribute('aria-invalid')).toBe('true'); + expect(cell(0).required).toBe(true); + expect(byTestId('root').getAttribute('data-invalid')).toBe('true'); + }); + + it('carries the whole value in a form, and takes the first value back at a reset', async () => { + render(PinInputFormTest, { defaultValue: '12' }); + + const hidden = () => + byTestId('form').elements.namedItem('code') as HTMLInputElement; + expect(hidden().value).toBe('12'); + + await userEvent.click(cell(2)); + await userEvent.keyboard('3'); + expect(hidden().value).toBe('123'); + + byTestId('reset').click(); + await new Promise((resolve) => queueMicrotask(() => resolve(null))); + await tick(); + + expect(hidden().value).toBe('12'); + }); +}); diff --git a/packages/ui/src/lib/pin-input/types.ts b/packages/ui/src/lib/pin-input/types.ts new file mode 100644 index 0000000..8ccbde1 --- /dev/null +++ b/packages/ui/src/lib/pin-input/types.ts @@ -0,0 +1,101 @@ +import type { Snippet } from 'svelte'; +import type { HTMLAttributes, HTMLInputAttributes } from 'svelte/elements'; +import type { PinInputChangeDetails, PinInputContext, PinInputType } from './root/context'; + +export type { PinInputChangeDetails, PinInputContext, PinInputType }; + +export type PinInputRootProps = { + /** A stable id, from which the component makes its internal ARIA ids. Give one on a server. */ + id?: string; + /** + * The value. It is never longer than `length`, and it has no holes: the characters fill the + * cells from the first one. You can bind it with `bind:value`. + */ + value?: string; + /** The value at the start, for when you give no `value`. */ + defaultValue?: string; + /** + * Give your own code full control of the value. The component stops to write back to `value`, + * and it reports only through `onChange`. Thus the parent can refuse a change. + */ + controlledValue?: boolean; + /** The component calls it on each change. */ + onChange?: (value: string, details: PinInputChangeDetails) => void; + /** The component calls it one time, when the last cell takes a character. */ + onComplete?: (value: string) => void; + /** The count of cells. The default is 6. */ + length?: number; + /** + * Which characters the cells accept: only the digits, the letters, or both. The default is + * `numeric`, which also sets the numeric keyboard on a telephone. + */ + type?: PinInputType; + /** A test of your own for one character. It replaces the test of `type`. */ + pattern?: RegExp; + /** + * Marks the cells as a code from a message. The telephone then offers the code of the last + * message above the keyboard, and one touch fills each cell. + */ + otp?: boolean; + /** Hides the characters, as a password field does. */ + mask?: boolean; + /** The text of an empty cell. */ + placeholder?: string; + /** Moves the focus off the last cell when the value is complete. */ + blurOnComplete?: boolean; + /** Disables each cell. The cells leave the tab order. */ + disabled?: boolean; + /** Keeps the cells in the tab order, but the value does not change. */ + readonly?: boolean; + /** Marks the value as necessary. Each cell gets `required`. */ + required?: boolean; + /** Marks the value as invalid. Each cell gets `aria-invalid`. */ + invalid?: boolean; + /** The name of the hidden input that carries the whole value in a form. */ + name?: string; + /** The id of the form that the hidden input belongs to, when the group is outside it. */ + form?: string; + /** The accessible name of the group, for when there is no `PinInput.Label`. */ + 'aria-label'?: string; + /** The id of the element that gives the group its name, in place of `PinInput.Label`. */ + 'aria-labelledby'?: string; + /** The id of the element that describes the group, for example an error message. */ + 'aria-describedby'?: string; + /** The content: the label and the cells. */ + children?: Snippet; + /** The CSS class names of the root element. */ + class?: string; + /** A bindable reference to the root element. */ + element?: HTMLDivElement | null; + /** A bindable reference to the context, for a composition of your own. */ + context?: PinInputContext; +} & Omit< + HTMLAttributes, + | 'class' + | 'children' + | 'id' + | 'role' + | 'aria-label' + | 'aria-labelledby' + | 'aria-describedby' + | 'onchange' +>; + +export type PinInputLabelProps = Omit, 'class'> & { + /** The CSS class names of the label. */ + class?: string; +}; + +export type PinInputCellProps = Omit< + HTMLInputAttributes, + 'class' | 'children' | 'value' | 'type' | 'aria-label' | 'maxlength' | 'size' +> & { + /** The index of the cell. Without it, the cells take the indexes in mount order. */ + index?: number; + /** The name of this cell, for the screen reader. The default is "Digit 2 of 6". */ + 'aria-label'?: string; + /** The CSS class names of the cell. */ + class?: string; + /** A bindable reference to the input element. */ + element?: HTMLInputElement | null; +}; diff --git a/packages/ui/src/lib/rating/README.md b/packages/ui/src/lib/rating/README.md new file mode 100644 index 0000000..0ebdbb9 --- /dev/null +++ b/packages/ui/src/lib/rating/README.md @@ -0,0 +1,58 @@ +# Rating + +## Description + +`Rating` is a form field with a value on a small scale, for example five stars. The user answers with a press or with the arrows. With whole items the rating is a radio group, because each value is one item. With half items it is a slider, because a radio group cannot say 3.5. + +## Anatomy + +- `Rating.Root` +- `Rating.Label` +- `Rating.Output` +- `Rating.Item` + +```svelte + + Quality + + {#each { length: 5 } as _, index (index)} + + {/each} + +``` + +## Usage guidelines + +- Use `bind:value` for the state, or `value` with `onChange` and `controlledValue` to hold it yourself. +- Give one `Rating.Item` for each item of `count`. +- Use `precision={0.5}` for half items. The root is a slider then, and the items are decoration. +- Paint each item with `--rating-item-fill`, which goes from 0 to 1. A half item has 0.5. +- `allowClear` is on: a press on the item of the value puts the value back to 0. `Delete` and `Backspace` do the same. +- Use `getValueText` and `getItemLabel` for the text of the value and of each item. +- The value follows the pointer before a press: `Rating.Output` and `--rating-display-value` show the value of the press before it. + +## API reference + +- `Rating.Root` + - `value?: number` + - `defaultValue?: number` + - `controlledValue?: boolean` + - `onChange?: (value, details) => void` + - `onChangeEnd?: (value, details) => void` + - `count?: number`, `precision?: number` + - `allowClear?: boolean` + - `disabled?: boolean`, `readonly?: boolean`, `required?: boolean`, `invalid?: boolean` + - `name?: string`, `form?: string` + - `getItemLabel?: (value, count) => string` + - `getValueText?: (value, count) => string` +- `Rating.Item` + - `index?: number` + - `aria-label?: string` + +## Accessibility + +- With a precision of 1 the root is a `role="radiogroup"`, and each item is a `role="radio"` with its own name, for example "3 of 5". The focus follows the value, which is the rule of a radio group. +- With a smaller precision the root is a `role="slider"` with `aria-valuenow` and `aria-valuetext`, and it is the one tab stop. +- The keyboard: the arrows step by the precision. `Home` goes to the first item, `End` goes to the last one, and `Delete` or `Backspace` clears the value. The horizontal arrows follow the text direction. +- `Rating.Output` is an `` for the root, with `aria-live="off"`. +- The value in a form comes from a hidden input. A `
` reset takes the first value back. diff --git a/packages/ui/src/lib/rating/TODO.md b/packages/ui/src/lib/rating/TODO.md new file mode 100644 index 0000000..f049f66 --- /dev/null +++ b/packages/ui/src/lib/rating/TODO.md @@ -0,0 +1,15 @@ +# Rating TODO + +## Goal + +Track Rating work with a single mandatory TODO format. + +## Backlog + +- [x] [S][P0][Area: Architecture][Owner: Unassigned][Target: Done] Create the `Root`, `Label`, `Output` and `Item` parts with namespace exports. +- [x] [S][P0][Area: Accessibility][Owner: Unassigned][Target: Done] Make the root a radio group with whole items, and a slider with a smaller precision. +- [x] [S][P0][Area: Keyboard][Owner: Unassigned][Target: Done] Handle the arrows, Home, End, Delete and Backspace, with the text direction. +- [x] [S][P0][Area: Pointer][Owner: Unassigned][Target: Done] Answer a press on an item, take the half of a press at a precision of 0.5, and follow the pointer. +- [x] [S][P0][Area: Forms][Owner: Unassigned][Target: Done] Send the value with a hidden input, and return to the default on a form reset. +- [ ] [S][P2][Area: API][Owner: Unassigned][Target: TBD] Add a `precision` of a third, which needs a fill of two parts in one item. +- [ ] [C][P3][Area: Pointer][Owner: Unassigned][Target: TBD] Add a touch drag across the items, which today answers only the press. diff --git a/packages/ui/src/lib/rating/index.parts.ts b/packages/ui/src/lib/rating/index.parts.ts new file mode 100644 index 0000000..46aafb9 --- /dev/null +++ b/packages/ui/src/lib/rating/index.parts.ts @@ -0,0 +1,4 @@ +export { default as Root } from './root/rating-root.svelte'; +export { default as Label } from './label/rating-label.svelte'; +export { default as Output } from './output/rating-output.svelte'; +export { default as Item } from './item/rating-item.svelte'; diff --git a/packages/ui/src/lib/rating/index.ts b/packages/ui/src/lib/rating/index.ts new file mode 100644 index 0000000..d52edf6 --- /dev/null +++ b/packages/ui/src/lib/rating/index.ts @@ -0,0 +1,24 @@ +export * as Rating from './index.parts.js'; + +export { default as RatingRoot } from './root/rating-root.svelte'; +export { default as RatingLabel } from './label/rating-label.svelte'; +export { default as RatingOutput } from './output/rating-output.svelte'; +export { default as RatingItem } from './item/rating-item.svelte'; + +export type { + RatingRootProps, + RatingLabelProps, + RatingOutputProps, + RatingOutputRenderState, + RatingItemProps, + RatingItemRenderState +} from './types.js'; + +export { + getRatingContext, + setRatingContext, + useRatingContext, + type RatingChangeDetails, + type RatingChangeReason, + type RatingContext +} from './root/context.js'; diff --git a/packages/ui/src/lib/rating/item/rating-item.svelte b/packages/ui/src/lib/rating/item/rating-item.svelte new file mode 100644 index 0000000..d4eb6dc --- /dev/null +++ b/packages/ui/src/lib/rating/item/rating-item.svelte @@ -0,0 +1,177 @@ + + + + + 0 && fill < 1) || undefined} + data-disabled={ctx.isDisabled || undefined} + data-readonly={ctx.isReadOnly || undefined} + data-focused={focused || undefined} + data-focus-visible={focusVisible || undefined} + onpointerdown={handlePointerDown} + onpointerenter={handlePointerEnter} + onpointermove={handlePointerMove} + onkeydown={handleKeyDown} + onfocus={handleFocus} + onblur={handleBlur} +> + {#if children} + {@render (children as Snippet<[RatingItemRenderState]>)(renderState)} + {/if} + diff --git a/packages/ui/src/lib/rating/label/rating-label.svelte b/packages/ui/src/lib/rating/label/rating-label.svelte new file mode 100644 index 0000000..d156d68 --- /dev/null +++ b/packages/ui/src/lib/rating/label/rating-label.svelte @@ -0,0 +1,42 @@ + + + + {@render children?.()} + diff --git a/packages/ui/src/lib/rating/output/rating-output.svelte b/packages/ui/src/lib/rating/output/rating-output.svelte new file mode 100644 index 0000000..1ba8c44 --- /dev/null +++ b/packages/ui/src/lib/rating/output/rating-output.svelte @@ -0,0 +1,38 @@ + + + + {#if children} + {@render children(renderState)} + {:else} + {text} + {/if} + diff --git a/packages/ui/src/lib/rating/root/context.ts b/packages/ui/src/lib/rating/root/context.ts new file mode 100644 index 0000000..c6d2a5b --- /dev/null +++ b/packages/ui/src/lib/rating/root/context.ts @@ -0,0 +1,103 @@ +import { getContext, setContext } from 'svelte'; + +/** How a value changed. */ +export type RatingChangeReason = 'keyboard' | 'pointer' | 'clear' | 'form-reset'; + +export type RatingChangeDetails = { + reason: RatingChangeReason; + event?: Event; +}; + +/** + * Context shared between Rating.Root and its parts. + */ +export type RatingContext = { + /** The instance id. Every ARIA id of the parts is made from it. */ + instanceId: string; + /** The id of the root element. */ + rootId: string; + /** The id of the `Rating.Label` element, when one is in the DOM. */ + labelId: string | null; + /** The value, from 0 to `count`. */ + value: number; + /** The value the user sees: the value under the pointer, or the value. */ + displayValue: number; + /** The count of items. */ + count: number; + /** The distance between two values. 1 gives whole stars, and 0.5 gives half stars. */ + precision: number; + /** Whether a press on the selected item puts the value back to 0. */ + allowClear: boolean; + isDisabled: boolean; + isReadOnly: boolean; + isRequired: boolean; + isInvalid: boolean; + /** Whether the pointer is on the items. */ + isHovering: boolean; + /** Whether the focus in the rating must show as keyboard focus. */ + isFocusVisible: boolean; + /** Whether the root is in a right-to-left context. */ + isRtl: boolean; + /** + * Whether each item is a radio. A precision of 1 makes a radio group, because each value is + * one item. A smaller precision makes a slider: a radio group cannot say 3.5. + */ + isRadioGroup: boolean; + /** The `aria-label` given to the root. */ + ariaLabel: string | undefined; + /** The `aria-labelledby` given to the root. */ + ariaLabelledBy: string | undefined; + /** The `aria-describedby` given to the root. */ + ariaDescribedBy: string | undefined; + /** The index of the item that has the focus, or null. */ + focusedIndex: number | null; + /** Registers the id of `Rating.Label`. Returns the unregister function. */ + registerLabel: (id: string) => () => void; + /** Registers an item. Without an index, the item takes the next free one, in mount order. */ + registerItem: (options: { index?: number; elementRef: () => HTMLElement | null }) => { + index: number; + unregister: () => void; + }; + /** The id of one item element. */ + getItemId: (index: number) => string; + /** How much of one item the value fills, from 0 to 1. */ + getItemFill: (itemValue: number) => number; + /** The text of one item, for example "3 Stars". */ + getItemLabel: (itemValue: number) => string; + /** The text of the value, for `Rating.Output` and for `aria-valuetext`. */ + getValueText: (value: number) => string; + /** The `tabindex` of one item. Null keeps the item out of the tab order. */ + getItemTabIndex: (itemValue: number) => number | null; + /** Writes a value. The value is snapped and clamped. Returns whether the value changed. */ + setValue: (value: number, details: RatingChangeDetails) => boolean; + /** Moves the value by one step, or to a value the keyboard names. */ + stepValue: (direction: -1 | 1, event?: Event) => boolean; + /** Reports the end of a sequence of changes: a key press, a press on an item. */ + commitValue: (details: RatingChangeDetails) => void; + /** Answers a press on an item, with the position of the pointer inside it. */ + pressItem: (itemValue: number, event: PointerEvent) => void; + /** Holds the value the pointer is on, for the preview. Null ends the preview. */ + setHoverValue: (value: number | null) => void; + /** Puts the focus on the item of a value. */ + focusValue: (value: number) => void; + setItemFocus: (index: number, focused: boolean) => void; + setFocusVisible: (visible: boolean) => void; +}; + +const RATING_KEY = Symbol('rating'); + +export function setRatingContext(ctx: RatingContext) { + setContext(RATING_KEY, ctx); +} + +export function getRatingContext(): RatingContext | undefined { + return getContext(RATING_KEY); +} + +export function useRatingContext(part: string): RatingContext { + const ctx = getRatingContext(); + if (!ctx) { + throw new Error(`${part} must be used inside a Rating.Root`); + } + return ctx; +} diff --git a/packages/ui/src/lib/rating/root/rating-form-test.svelte b/packages/ui/src/lib/rating/root/rating-form-test.svelte new file mode 100644 index 0000000..5a401a9 --- /dev/null +++ b/packages/ui/src/lib/rating/root/rating-form-test.svelte @@ -0,0 +1,20 @@ + + + + + {#each { length: count } as _, index (index)} + + {/each} + + + diff --git a/packages/ui/src/lib/rating/root/rating-root.svelte b/packages/ui/src/lib/rating/root/rating-root.svelte new file mode 100644 index 0000000..463db43 --- /dev/null +++ b/packages/ui/src/lib/rating/root/rating-root.svelte @@ -0,0 +1,521 @@ + + + + +
+ {#if name} + + {/if} + {@render children?.()} +
diff --git a/packages/ui/src/lib/rating/root/rating-ssr.test.ts b/packages/ui/src/lib/rating/root/rating-ssr.test.ts new file mode 100644 index 0000000..e734833 --- /dev/null +++ b/packages/ui/src/lib/rating/root/rating-ssr.test.ts @@ -0,0 +1,47 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest'; +import { render } from 'svelte/server'; +import RatingTest from './rating-test.svelte'; + +function getTag(source: string, marker: string) { + const markerIndex = source.indexOf(marker); + if (markerIndex === -1) return ''; + const tagStart = source.lastIndexOf('<', markerIndex); + const tagEnd = source.indexOf('>', markerIndex); + if (tagStart === -1 || tagEnd === -1) return ''; + return source.slice(tagStart, tagEnd + 1); +} + +describe('Rating SSR', () => { + it('renders the radio group, the value and the fill before hydration', () => { + const { body } = render(RatingTest, { props: { defaultValue: 3 } }); + + const root = getTag(body, 'data-testid="root"'); + expect(root).toContain('role="radiogroup"'); + expect(root).toContain('--rating-value: 3'); + expect(getTag(body, 'data-testid="item-2"')).toContain('aria-checked="true"'); + expect(getTag(body, 'data-testid="item-2"')).toContain('--rating-item-fill: 1'); + expect(getTag(body, 'data-testid="item-3"')).toContain('--rating-item-fill: 0'); + expect(body).toContain('3 of 5'); + }); + + it('renders the slider of a half rating before hydration', () => { + const { body } = render(RatingTest, { props: { defaultValue: 2.5, precision: 0.5 } }); + + const root = getTag(body, 'data-testid="root"'); + expect(root).toContain('role="slider"'); + expect(root).toContain('aria-valuenow="2.5"'); + expect(root).toContain('aria-valuetext="2.5 of 5"'); + }); + + it('reports nothing while rendering', () => { + const changes: unknown[] = []; + + render(RatingTest, { + props: { defaultValue: 3, onChange: (value: unknown) => changes.push(value) } + }); + + expect(changes).toEqual([]); + }); +}); diff --git a/packages/ui/src/lib/rating/root/rating-test.svelte b/packages/ui/src/lib/rating/root/rating-test.svelte new file mode 100644 index 0000000..c64e03b --- /dev/null +++ b/packages/ui/src/lib/rating/root/rating-test.svelte @@ -0,0 +1,79 @@ + + +
+ + + {#if withLabel} + Quality + {/if} + + {#each { length: count } as _, index (index)} + + {/each} + + + {JSON.stringify(value)} +
diff --git a/packages/ui/src/lib/rating/root/rating-utils.test.ts b/packages/ui/src/lib/rating/root/rating-utils.test.ts new file mode 100644 index 0000000..962ca98 --- /dev/null +++ b/packages/ui/src/lib/rating/root/rating-utils.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { + getRatingItemFill, + getRatingValueFromPointer, + normalizeRatingCount, + normalizeRatingPrecision, + snapRatingValue +} from './rating-utils'; + +describe('rating utils', () => { + it('keeps the precision between 0 and 1', () => { + expect(normalizeRatingPrecision(0.5)).toBe(0.5); + expect(normalizeRatingPrecision(0)).toBe(1); + expect(normalizeRatingPrecision(2)).toBe(1); + expect(normalizeRatingPrecision(Number.NaN)).toBe(1); + }); + + it('keeps the count a whole number of 1 or more', () => { + expect(normalizeRatingCount(10)).toBe(10); + expect(normalizeRatingCount(5.7)).toBe(5); + expect(normalizeRatingCount(0)).toBe(5); + }); + + it('snaps a value to the scale, without the error of the binary fractions', () => { + expect(snapRatingValue(2.3, 5, 0.5)).toBe(2.5); + expect(snapRatingValue(2.2, 5, 0.5)).toBe(2); + expect(snapRatingValue(0.1 + 0.2, 5, 0.1)).toBe(0.3); + expect(snapRatingValue(9, 5, 1)).toBe(5); + expect(snapRatingValue(-3, 5, 1)).toBe(0); + }); + + it('reads the value under the pointer from the left edge, or the right edge in RTL', () => { + const rect = { left: 0, width: 100 }; + + expect(getRatingValueFromPointer(10, rect, { count: 5, precision: 1, rtl: false })).toBe(1); + expect(getRatingValueFromPointer(45, rect, { count: 5, precision: 1, rtl: false })).toBe(3); + expect(getRatingValueFromPointer(100, rect, { count: 5, precision: 1, rtl: false })).toBe(5); + expect(getRatingValueFromPointer(10, rect, { count: 5, precision: 1, rtl: true })).toBe(5); + }); + + it('gives one step to a press on the left edge, and never zero', () => { + const rect = { left: 0, width: 100 }; + + expect(getRatingValueFromPointer(0, rect, { count: 5, precision: 1, rtl: false })).toBe(1); + expect(getRatingValueFromPointer(0, rect, { count: 5, precision: 0.5, rtl: false })).toBe(0.5); + }); + + it('fills one item from empty to full across one step of the value', () => { + expect(getRatingItemFill(3, 2)).toBe(0); + expect(getRatingItemFill(3, 2.5)).toBe(0.5); + expect(getRatingItemFill(3, 3)).toBe(1); + expect(getRatingItemFill(3, 4)).toBe(1); + }); +}); diff --git a/packages/ui/src/lib/rating/root/rating-utils.ts b/packages/ui/src/lib/rating/root/rating-utils.ts new file mode 100644 index 0000000..c11c8be --- /dev/null +++ b/packages/ui/src/lib/rating/root/rating-utils.ts @@ -0,0 +1,58 @@ +/** The value of one step. A precision above 1, or a precision that is not a number, becomes 1. */ +export function normalizeRatingPrecision(precision: number): number { + if (!Number.isFinite(precision) || precision <= 0 || precision > 1) return 1; + return precision; +} + +/** The count of stars. A count below 1, or a count that is not a number, becomes 5. */ +export function normalizeRatingCount(count: number): number { + if (!Number.isFinite(count) || count < 1) return 5; + return Math.floor(count); +} + +/** + * The nearest value on the scale. The result is a multiple of the precision, between 0 and the + * count. The multiplication removes the error of the binary fractions: 0.1 + 0.2 is 0.30000000004, + * and a rating of 0.3 must stay 0.3. + */ +export function snapRatingValue(value: number, count: number, precision: number): number { + if (!Number.isFinite(value)) return 0; + const steps = Math.round(value / precision); + const maxSteps = Math.round(count / precision); + const clamped = Math.min(Math.max(steps, 0), maxSteps); + const decimals = getDecimalCount(precision); + return Number((clamped * precision).toFixed(decimals)); +} + +function getDecimalCount(precision: number): number { + const text = String(precision); + const dot = text.indexOf('.'); + return dot === -1 ? 0 : Math.min(text.length - dot - 1, 10); +} + +/** + * The value under the pointer. The measure starts at the left edge of the row, or at the right + * edge in a right-to-left context. The value is always at least one step: a press on the first + * star gives one star, and never zero. + */ +export function getRatingValueFromPointer( + clientX: number, + rect: { left: number; width: number }, + options: { count: number; precision: number; rtl: boolean } +): number { + const { count, precision, rtl } = options; + if (rect.width <= 0) return precision; + const distance = rtl ? rect.left + rect.width - clientX : clientX - rect.left; + const fraction = Math.min(Math.max(distance / rect.width, 0), 1); + const raw = Math.ceil((fraction * count) / precision) * precision; + return Math.max(snapRatingValue(raw, count, precision), precision); +} + +/** + * How much of one star the value fills, from 0 to 1. The star at 3 is full while the value is 3 + * or more, it is empty at 2 or less, and it is half full at 2.5. + */ +export function getRatingItemFill(itemValue: number, value: number): number { + const fill = value - (itemValue - 1); + return Math.min(Math.max(fill, 0), 1); +} diff --git a/packages/ui/src/lib/rating/root/rating.test.ts b/packages/ui/src/lib/rating/root/rating.test.ts new file mode 100644 index 0000000..157bf44 --- /dev/null +++ b/packages/ui/src/lib/rating/root/rating.test.ts @@ -0,0 +1,353 @@ +import { tick } from 'svelte'; +import { describe, expect, it, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import { userEvent } from 'vitest/browser'; +import { + expectFocusVisibleImpliesFocusWithin, + expectNoFalseFocusAttributes +} from '../../test-utils/focus-contract'; +import type { RatingChangeDetails } from '../types'; +import RatingFormTest from './rating-form-test.svelte'; +import RatingTest from './rating-test.svelte'; + +function byTestId(id: string): T { + const element = document.querySelector(`[data-testid="${id}"]`); + if (!element) throw new Error(`No element with data-testid="${id}"`); + return element; +} + +function item(index: number): HTMLElement { + return byTestId(`item-${index}`); +} + +function boundValue(): number | null { + return JSON.parse(byTestId('bound-value').textContent ?? 'null'); +} + +/** Dispatches a pointer event on an element, and waits for the DOM to catch up. */ +async function pointer(target: Element, type: string, clientX?: number) { + const rect = target.getBoundingClientRect(); + target.dispatchEvent( + new PointerEvent(type, { + clientX: clientX ?? rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + pointerId: 1, + button: 0, + buttons: type === 'pointerup' ? 0 : 1, + bubbles: true, + cancelable: true, + composed: true + }) + ); + await tick(); +} + +/** A press on the left part of an item, where a half rating is. */ +async function pressHalf(target: Element) { + const rect = target.getBoundingClientRect(); + await pointer(target, 'pointerdown', rect.left + rect.width * 0.25); +} + +describe('Rating', () => { + it('renders a radio group named by the label, with one radio per item', async () => { + const screen = render(RatingTest, { defaultValue: 3 }); + + const group = screen.getByRole('radiogroup', { name: 'Quality' }); + expect(group.element()).toBe(byTestId('root')); + expect(byTestId('root').getAttribute('aria-labelledby')).toBe(byTestId('label').id); + + const radios = screen.getByRole('radio').all(); + expect(radios).toHaveLength(5); + expect(item(2).getAttribute('aria-checked')).toBe('true'); + expect(item(1).getAttribute('aria-checked')).toBe('false'); + expect(item(2).getAttribute('aria-label')).toBe('3 of 5'); + expect(byTestId('output').textContent?.trim()).toBe('3 of 5'); + }); + + it('fills each item up to the value, and part of one item at a half rating', async () => { + render(RatingTest, { defaultValue: 2.5, precision: 0.5 }); + + expect(item(1).style.getPropertyValue('--rating-item-fill')).toBe('1'); + expect(item(2).style.getPropertyValue('--rating-item-fill')).toBe('0.5'); + expect(item(3).style.getPropertyValue('--rating-item-fill')).toBe('0'); + expect(item(2).getAttribute('data-partial')).toBe('true'); + expect(item(1).hasAttribute('data-partial')).toBe(false); + }); + + it('writes the value of the item the pointer presses', async () => { + const changes: Array<[number, RatingChangeDetails]> = []; + const ends: number[] = []; + render(RatingTest, { + onChange: (value, details) => changes.push([value, details]), + onChangeEnd: (value) => ends.push(value) + }); + + await pointer(item(3), 'pointerdown'); + + expect(boundValue()).toBe(4); + expect(changes).toHaveLength(1); + expect(changes[0][0]).toBe(4); + expect(changes[0][1].reason).toBe('pointer'); + expect(ends).toEqual([4]); + }); + + it('takes the half the pointer is on at a precision of 0.5', async () => { + render(RatingTest, { precision: 0.5 }); + + await pressHalf(item(2)); + + expect(boundValue()).toBe(2.5); + }); + + it('puts the value back to 0 on a press on the value, and reports the reason', async () => { + const reasons: string[] = []; + render(RatingTest, { + defaultValue: 4, + onChange: (_value, details) => reasons.push(details.reason) + }); + + await pointer(item(3), 'pointerdown'); + + expect(boundValue()).toBe(0); + expect(reasons).toEqual(['clear']); + }); + + it('keeps the value on a press on the value when allowClear is off', async () => { + render(RatingTest, { defaultValue: 4, allowClear: false }); + + await pointer(item(3), 'pointerdown'); + + expect(boundValue()).toBe(4); + }); + + it('shows the value under the pointer, and the value again when the pointer leaves', async () => { + render(RatingTest, { defaultValue: 1 }); + + await pointer(item(3), 'pointerenter'); + + expect(byTestId('output').textContent?.trim()).toBe('4 of 5'); + expect(byTestId('root').getAttribute('data-hovering')).toBe('true'); + expect(item(2).getAttribute('data-highlighted')).toBe('true'); + expect(boundValue()).toBe(1); + + await pointer(byTestId('root'), 'pointerleave'); + + expect(byTestId('output').textContent?.trim()).toBe('1 of 5'); + expect(byTestId('root').hasAttribute('data-hovering')).toBe(false); + }); + + it('leaves no preview after a press with a finger', async () => { + render(RatingTest, { defaultValue: 1 }); + + const target = item(3); + const rect = target.getBoundingClientRect(); + for (const type of ['pointerenter', 'pointerdown', 'pointerup']) { + target.dispatchEvent( + new PointerEvent(type, { + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + pointerId: 7, + pointerType: 'touch', + button: 0, + buttons: type === 'pointerup' ? 0 : 1, + bubbles: true, + cancelable: true, + composed: true + }) + ); + await tick(); + } + + // A finger cannot rest on an item, and the leave that ends a preview does not come from + // each browser: a preview that stays on is a preview that never goes. + expect(boundValue()).toBe(4); + expect(byTestId('root').hasAttribute('data-hovering')).toBe(false); + expect(byTestId('output').textContent?.trim()).toBe('4 of 5'); + }); + + it('moves the value with the arrows, and carries the focus with it', async () => { + render(RatingTest, { defaultValue: 2 }); + + await userEvent.keyboard('{Tab}{Tab}'); + expect(document.activeElement).toBe(item(1)); + + await userEvent.keyboard('{ArrowRight}'); + expect(boundValue()).toBe(3); + expect(document.activeElement).toBe(item(2)); + expect(item(2).getAttribute('data-focus-visible')).toBe('true'); + + await userEvent.keyboard('{ArrowLeft}{ArrowLeft}'); + expect(boundValue()).toBe(1); + expect(document.activeElement).toBe(item(0)); + + expectFocusVisibleImpliesFocusWithin(byTestId('root')); + expectNoFalseFocusAttributes(); + }); + + it('mirrors the arrows in a right-to-left context', async () => { + render(RatingTest, { defaultValue: 2, dir: 'rtl' }); + + await userEvent.keyboard('{Tab}{Tab}'); + await userEvent.keyboard('{ArrowLeft}'); + + expect(boundValue()).toBe(3); + }); + + it('goes to the first item with Home, to the last with End, and to 0 with Delete', async () => { + render(RatingTest, { defaultValue: 3 }); + + await userEvent.keyboard('{Tab}{Tab}'); + await userEvent.keyboard('{End}'); + expect(boundValue()).toBe(5); + + await userEvent.keyboard('{Home}'); + expect(boundValue()).toBe(1); + + await userEvent.keyboard('{Delete}'); + expect(boundValue()).toBe(0); + }); + + it('reports one end for a held key, and one for each press', async () => { + const ends: number[] = []; + render(RatingTest, { defaultValue: 1, onChangeEnd: (value) => ends.push(value) }); + + await userEvent.keyboard('{Tab}{Tab}'); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{ArrowRight}'); + + expect(ends).toEqual([2, 3]); + }); + + it('answers Space on the item that has the focus', async () => { + render(RatingTest); + + await userEvent.keyboard('{Tab}{Tab}'); + expect(document.activeElement).toBe(item(0)); + + await userEvent.keyboard(' '); + + expect(boundValue()).toBe(1); + }); + + it('is a slider at a precision below 1, with one tab stop and the text of the value', async () => { + const screen = render(RatingTest, { defaultValue: 2.5, precision: 0.5 }); + + const slider = screen.getByRole('slider', { name: 'Quality' }); + expect(slider.element()).toBe(byTestId('root')); + expect(byTestId('root').getAttribute('aria-valuenow')).toBe('2.5'); + expect(byTestId('root').getAttribute('aria-valuetext')).toBe('2.5 of 5'); + expect(byTestId('root').getAttribute('aria-valuemax')).toBe('5'); + expect(screen.getByRole('radio').all()).toHaveLength(0); + + await userEvent.keyboard('{Tab}{Tab}'); + expect(document.activeElement).toBe(byTestId('root')); + + await userEvent.keyboard('{ArrowRight}'); + expect(boundValue()).toBe(3); + expect(byTestId('root').getAttribute('aria-valuenow')).toBe('3'); + }); + + it('takes the focus at a press, thus the arrows answer without a Tab first', async () => { + render(RatingTest, { defaultValue: 3.5, precision: 0.5 }); + + // A slider has one tab stop, and it is the root. Without the focus the arrows go to the + // body, and the rating answers nothing until the reader presses Tab. + // The centre of an item is the border of its two halves, thus this press gives the half. + await pointer(item(2), 'pointerdown'); + + expect(document.activeElement).toBe(byTestId('root')); + expect(boundValue()).toBe(2.5); + + await userEvent.keyboard('{ArrowRight}'); + expect(boundValue()).toBe(3); + // A press shows no focus ring. + expectNoFalseFocusAttributes(); + }); + + it('says that a radio group is read only', async () => { + render(RatingTest, { defaultValue: 2, readonly: true }); + + expect(byTestId('root').getAttribute('role')).toBe('radiogroup'); + expect(byTestId('root').getAttribute('aria-readonly')).toBe('true'); + }); + + it('says no rating at 0', async () => { + render(RatingTest, { defaultValue: 0, precision: 0.5 }); + + expect(byTestId('output').textContent?.trim()).toBe('No rating'); + expect(byTestId('root').getAttribute('aria-valuetext')).toBe('No rating'); + }); + + it('takes the text of the value and of each item from the consumer', async () => { + render(RatingTest, { + defaultValue: 2, + getValueText: (value, count) => `${value}/${count} stars`, + getItemLabel: (value) => `${value} stars` + }); + + expect(byTestId('output').textContent?.trim()).toBe('2/5 stars'); + expect(item(1).getAttribute('aria-label')).toBe('2 stars'); + }); + + it('changes nothing while disabled, and leaves the items out of the tab order', async () => { + const onChange = vi.fn(); + render(RatingTest, { defaultValue: 2, disabled: true, onChange }); + + await pointer(item(4), 'pointerdown'); + expect(boundValue()).toBe(2); + + await userEvent.keyboard('{Tab}{Tab}'); + expect(document.activeElement).toBe(byTestId('after')); + expect(onChange).not.toHaveBeenCalled(); + expect(byTestId('root').getAttribute('data-disabled')).toBe('true'); + expect(byTestId('root').getAttribute('aria-disabled')).toBe('true'); + }); + + it('keeps the items in the tab order while readonly, and changes nothing', async () => { + render(RatingTest, { defaultValue: 2, readonly: true }); + + await userEvent.keyboard('{Tab}{Tab}'); + expect(document.activeElement).toBe(item(1)); + + await userEvent.keyboard('{ArrowRight}'); + expect(boundValue()).toBe(2); + + await pointer(item(4), 'pointerenter'); + expect(byTestId('root').hasAttribute('data-hovering')).toBe(false); + }); + + it('refuses the change in controlled mode until the parent sends the value', async () => { + const onChange = vi.fn(); + render(RatingTest, { value: 2, controlledValue: true, onChange }); + + await pointer(item(3), 'pointerdown'); + + expect(onChange).toHaveBeenCalledWith(4, expect.objectContaining({ reason: 'pointer' })); + expect(item(1).getAttribute('aria-checked')).toBe('true'); + expect(item(3).getAttribute('aria-checked')).toBe('false'); + }); + + it('marks the rating as necessary and as invalid', async () => { + render(RatingTest, { required: true, invalid: true }); + + expect(byTestId('root').getAttribute('aria-required')).toBe('true'); + expect(byTestId('root').getAttribute('aria-invalid')).toBe('true'); + expect(byTestId('root').getAttribute('data-invalid')).toBe('true'); + }); + + it('carries the value in a form, and takes the first value back at a reset', async () => { + render(RatingFormTest, { defaultValue: 2 }); + + const hidden = () => byTestId('form').elements.namedItem('quality'); + expect((hidden() as HTMLInputElement).value).toBe('2'); + + await pointer(item(4), 'pointerdown'); + expect((hidden() as HTMLInputElement).value).toBe('5'); + + byTestId('reset').click(); + await new Promise((resolve) => queueMicrotask(() => resolve(null))); + await tick(); + + expect((hidden() as HTMLInputElement).value).toBe('2'); + }); +}); diff --git a/packages/ui/src/lib/rating/types.ts b/packages/ui/src/lib/rating/types.ts new file mode 100644 index 0000000..f88ae0f --- /dev/null +++ b/packages/ui/src/lib/rating/types.ts @@ -0,0 +1,144 @@ +import type { Snippet } from 'svelte'; +import type { HTMLAttributes } from 'svelte/elements'; +import type { RatingChangeDetails, RatingContext } from './root/context'; + +export type { RatingChangeDetails, RatingContext }; + +export type RatingRootProps = { + /** A stable id, from which the component makes its internal ARIA ids. Give one on a server. */ + id?: string; + /** The value, from 0 to `count`. 0 is no rating. You can bind it with `bind:value`. */ + value?: number; + /** The value at the start, for when you give no `value`. The default is 0. */ + defaultValue?: number; + /** + * Give your own code full control of the value. The component stops to write back to `value`, + * and it reports only through `onChange`. Thus the parent can refuse a change. + */ + controlledValue?: boolean; + /** The component calls it on each change. */ + onChange?: (value: number, details: RatingChangeDetails) => void; + /** + * The component calls it when a sequence of changes ends: at the release of a key, and at the + * press on an item. Use it for work that must not run on each key repeat. + */ + onChangeEnd?: (value: number, details: RatingChangeDetails) => void; + /** The count of items. The default is 5. */ + count?: number; + /** + * The distance between two values. The default is 1, which gives whole items. 0.5 gives half + * items. A precision below 1 makes the rating a slider, because a radio group cannot say 3.5. + */ + precision?: number; + /** + * A press on the item of the current value puts the value back to 0. `Delete` and `Backspace` + * do the same. The default is on. + */ + allowClear?: boolean; + /** Disables the rating. The items leave the tab order, and the value does not change. */ + disabled?: boolean; + /** Keeps the items in the tab order, but the value does not change. */ + readonly?: boolean; + /** Marks the rating as necessary. The root gets `aria-required`. */ + required?: boolean; + /** Marks the value as invalid. The root gets `aria-invalid`. */ + invalid?: boolean; + /** The name of the hidden input that carries the value in a form. */ + name?: string; + /** The id of the form that the hidden input belongs to, when the rating is outside it. */ + form?: string; + /** + * The text of one item, for the screen reader. The default is "3 of 5" in the locale. The + * second argument is the count. + */ + getItemLabel?: (value: number, count: number) => string; + /** + * The text of the value, for `Rating.Output` and for the text a screen reader reads. The + * default is "3 of 5" in the locale, and "No rating" at 0. + */ + getValueText?: (value: number, count: number) => string; + /** The accessible name of the rating, for when there is no `Rating.Label`. */ + 'aria-label'?: string; + /** The id of the element that gives the rating its name, in place of `Rating.Label`. */ + 'aria-labelledby'?: string; + /** The id of the element that describes the rating, for example an error message. */ + 'aria-describedby'?: string; + /** The content: the label, the output and the items. */ + children?: Snippet; + /** The CSS class names of the root element. */ + class?: string; + /** A bindable reference to the root element. */ + element?: HTMLDivElement | null; + /** A bindable reference to the context, for a composition of your own. */ + context?: RatingContext; +} & Omit< + HTMLAttributes, + | 'class' + | 'children' + | 'id' + | 'role' + | 'aria-label' + | 'aria-labelledby' + | 'aria-describedby' + | 'onchange' +>; + +export type RatingLabelProps = Omit, 'class'> & { + /** The CSS class names of the label. */ + class?: string; +}; + +export type RatingOutputRenderState = { + /** The value. */ + value: number; + /** The value the user sees: the value under the pointer, or the value. */ + displayValue: number; + /** The count of items. */ + count: number; + /** The text of the value, in the locale. */ + text: string; +}; + +export type RatingOutputProps = Omit, 'class' | 'children'> & { + /** + * The content. As a snippet with one argument, it receives the render state: `value`, + * `displayValue`, `count` and `text`. Without it, the component shows `text`. + */ + children?: Snippet<[RatingOutputRenderState]>; + /** The CSS class names of the element. */ + class?: string; +}; + +export type RatingItemRenderState = { + /** The value of the item: 1 for the first one. */ + value: number; + /** The index of the item: 0 for the first one. */ + index: number; + /** How much of the item the value fills, from 0 to 1. */ + fill: number; + /** Whether the item is the value. */ + selected: boolean; + /** Whether the value, or the pointer, is at this item or past it. */ + highlighted: boolean; + /** Whether the item has the focus. */ + focused: boolean; +}; + +export type RatingItemProps = Omit< + HTMLAttributes, + 'class' | 'children' | 'aria-label' +> & { + /** The index of the item. Without it, the items take the indexes in mount order. */ + index?: number; + /** The name of this item, for the screen reader. It replaces `getItemLabel` of the root. */ + 'aria-label'?: string; + /** + * The content. As a snippet with one argument, it receives the render state: `value`, `index`, + * `fill`, `selected`, `highlighted` and `focused`. + */ + children?: Snippet<[RatingItemRenderState]> | Snippet; + /** The CSS class names of the item. */ + class?: string; + /** A bindable reference to the item element. */ + element?: HTMLSpanElement | null; +};