The Jx UI kit
@jxsuite/ui
is the set of interface elements Jx Studio's chrome is built from. Each element is a Jx document, interpreted by the runtime. The kit also carries the theme those elements draw on and the icons they use. You can register the same elements in a site.
Register the elements
import { registerUi } from "@jxsuite/ui";
await registerUi();
registerUi()
defines every kit element in the current page and adopts the theme. It reads its documents from your bundle, so nothing is fetched. Calling it again does nothing.
Pass
{ theme: false }
to skip the theme, or
{ document }
to target another document.
Use the theme
The theme is one stylesheet of custom properties on
:root
, inside the cascade layer
jx-ui
. Because your own rules are unlayered, they always win over a token.
{
"style": {
"--jx-accent": "#7c3aed",
"--jx-radius-sm": "6px"
}
}
Every colour token is a
light-dark()
pair, so the theme follows the operating system. Set
data-theme="light"
or
data-theme="dark"
on the root element to force one.
Density is a second attribute on the root element.
data-density="compact"
steps the body type down one size (
--jx-text-md
to 11px,
--jx-leading-md
to 16px), so more of a label fits a row and a panel reads smaller;
data-density="comfortable"
raises the control height to 28px. The control height itself never goes below 24px at any density: WCAG 2.2 SC 2.5.8 asks 24 CSS px of a pointer target, and the kit holds every density to that floor with a test. The floor covers what that token sizes. A control's own
size="sm"
draws 4px under it, and it is your choice per control rather than a density, so leave room around
sm
controls that sit side by side. A few targets sit inside a control and are smaller than its box: the number field's steppers, the text field's clear button, the combobox's toggle and the split handle. The spec names them as the criterion's stated exceptions.
The tokens live in the kit's own
project.json
. Open that file in Studio to edit them on the canvas.
Style a part
The kit ships no CSS class. Every node inside an element carries a
part
, and the element's own
style
object is where its drawing lives. Because the elements are light DOM, a part is an ordinary descendant you can address from where you use the element:
{
"tagName": "jx-button",
"style": {
"& > [part=\"control\"]": { "minWidth": "8rem" }
},
"children": [{ "tagName": "span", "textContent": "Publish" }]
}
A
style
object written where the element is used merges with the element's own and wins, so one instance can be drawn differently without a rule reaching into the kit. A rule on an ancestor works the same way:
"& jx-button > [part=\"control\"]"
. Each section below names an element's parts. A part whose name ends in
-slot
is the box around something that may not be there: a named slot, or a branch the element draws only in some states. It is what the element asks
:empty
of, since a slot itself leaves no node.
Draw an icon
{ "tagName": "jx-icon", "attributes": { "name": "plus", "label": "Add" } }
jx-icon
draws one glyph.
name
is a Phosphor glyph the kit ships.
weight
is
regular
,
bold
or
fill
.
label
gives the icon an accessible name; leave it out when visible text already names the control, and the icon stays hidden from assistive technology.
mirror
flips the drawing for a right-to-left layout.
The list of shipped glyphs is
icons/list.json
in the package. Add a name there and run
bun run build:icons
to extend it. A weight the manifest lacks falls back to
regular
. The parts are
svg
and
path
.
Dividers, key caps and dots
Three elements own a box and nothing else. They are elements because two or more definitions draw the same box, and a definition's
style
object is the only place a declaration may live.
jx-divider
is a hairline: one
<hr part="rule">
the platform already announces as a separator.
orientation
is
horizontal
, the default, or
vertical
. The vertical branch makes the host a flex box exactly as wide as the rule it holds, so it stretches to a toolbar's height and takes no width beyond the hairline. The host still has a box, one client rect coincident with the rule, which is what the canvas selects and drops onto.
hidden
removes the hairline and its advance in either orientation.
{ "tagName": "jx-divider", "$props": { "orientation": "vertical" } }
jx-kbd
is a key cap in running text. Its content is the key. It is an atomic inline box, so it sits on the text baseline and a line break falls between caps rather than through one.
{ "tagName": "jx-kbd", "textContent": "Ctrl" }
jx-dot
is a six-pixel status disc.
tone
is
danger
,
success
or
warning
; anything else, the empty default included, is the neutral disc. It recolours the disc and nothing else: the dot carries no name of its own, so a row that means something by it says so in its own text or names the dot where it is used.
{ "tagName": "jx-dot", "$props": { "tone": "success" } }
Buttons
jx-button
wraps one native
<button>
, so everything the platform gives a button (a form submission,
popovertarget
,
command
and
commandfor
, focus, the keyboard) works through it. Its text is its label;
variant
says how much it asks for.
{
"tagName": "jx-button",
"$props": { "variant": "accent", "command": "show-modal", "commandfor": "confirm-delete" },
"children": [
{ "tagName": "jx-icon", "attributes": { "slot": "icon" }, "$props": { "name": "trash" } },
{ "tagName": "span", "textContent": "Delete…" }
]
}
variant
is
accent
,
primary
,
secondary
(the default) or
negative
.
size
is
sm
,
md
or
lg
.
quiet
drops the fill and border until hovered.
disabled
disables the control.
loading
keeps the button's width, shows a spinner, says
aria-busy
and swallows the next activation.
type
is
button
, the default,
submit
or
reset
. When the visible text is not the name, or there is none, give it a
label
;
labelledby
and
describedby
forward to the control too.
hint
is a tooltip beside the name, for a button whose text is cut short or whose disabled state needs a reason, and it works exactly as it does on
jx-action-button
below.
autofocus
forwards as well, so a button inside a dialog can claim the focus its
showModal()
would otherwise give to whichever control comes first. The
icon
slot draws a glyph before the text. The parts are
control
,
icon
,
label
,
spinner
,
tooltip
,
tip
and
tip-text
.
A click reaches you on the element, and a
loading
button stops it at the control inside, so your own listener never runs for an activation the button refused. That is why the swallow is on the inner control rather than the host:
stopPropagation
halts an event at the node it is called on and every node after it, never the other listeners on that same node.
tabindex
is a property, not an attribute, on every kit control that wraps a native one:
jx-button
,
jx-action-button
,
jx-textfield
,
jx-checkbox
,
jx-switch
,
jx-swatch
and
jx-color-field
. Write
el.tabindex = "0"
and it reaches the control inside, which is the node that takes focus. Write it as an attribute and it lands on the host as well, and a host carrying
tabindex
is itself focusable, so one control becomes two tab stops. A container that roves a caret, such as
jx-toolbar
, writes the property.
jx-action-button
is the icon-first tool button a toolbar is made of.
label
is required, because its name is not on screen. It is the accessible name only: set
hint
as well if you want a tooltip, which an icon-only button usually should. They were one prop, and a button whose text is already readable does not need its own label repeated on hover.
icon
names a glyph,
weight
is its weight, and
mirror
flips it so one shipped glyph serves both sides of a pair.
toggles
makes it a two-state button that carries
aria-pressed
, flips
selected
when activated and dispatches
change
with the new state. A host that owns the state sets
selected
itself, and the property wins.
{ "tagName": "jx-action-button", "$props": { "label": "Bold", "icon": "text-b", "toggles": true } }
It is
quiet
by default.
emphasized
draws the selected state in the accent.
stacked
puts the icon above a visible label, the shape of a rail button. A button that opens a menu rather than running a command sets
haspopup
and
expanded
, which reach the control as
aria-haspopup
and
aria-expanded
.
badge
draws a count over the button's corner, and nothing when empty.
size
,
disabled
,
loading
,
label
,
labelledby
,
describedby
and the invoker attributes work as they do on
jx-button
;
loading
here puts the spinner where the glyph was, since an action button has no icon slot to put one in.
checked
,
"true"
or
"false"
, makes the button one segment of a radio group instead of a toggle, and the two are alternatives rather than companions: a button that is one of several choices is a radio, a button that is independently on or off is a toggle, and where both are set
checked
wins and the button neither says
aria-pressed
nor flips itself. The parts are
control
,
icon
,
icon-glyph
,
spinner
,
label
,
badge-slot
,
badge
,
tooltip
,
tip
and
tip-text
.
A
hint
is a real tooltip while the button can act. The element renders a
jx-tooltip
as its own child, wires the control to it (
interestfor
and
aria-describedby
on the control,
for
on the tip, all from an id the kit mints, so you write none of them), and the tip opens at once on keyboard focus and after a short delay on hover, stays while you hover it, and closes on Escape. A click does not open it: focus from the pointer is not the focus a tip answers, on the platform's path or the kit's. A button that is moved in the document, by a list reordering its rows or by you re-parenting it, keeps its tip. It describes the button rather than naming it, so a screen reader hears the label and then the hint. While the button is
disabled
there is no tip, because a disabled control cannot open one: the hint becomes the control's native
title
instead, which still shows on hover, and that is where a reason such as "requires a page" belongs. Toggle
disabled
and the two swap. Read a hint in a test from the
jx-tooltip
child's text, or from the id in the control's
aria-describedby
, and from
title
only on a disabled button.
Text fields
jx-textfield
wraps one native
<input>
, or with
multiline
a
<textarea>
, so the platform's own editing, form participation and keyboard work through it.
label
is its accessible name and reaches the control as
aria-label
; give one unless a visible label points at the control.
value
is written by whoever types and by the host, and a write equal to what is already there never moves the caret.
{
"tagName": "jx-textfield",
"$props": {
"label": "Layout name",
"placeholder": "Untitled",
"value": { "$ref": "#/state/name" }
}
}
The native
input
and
change
events bubble from the field as they always do.
invalid
marks a refused value and reaches the control as
aria-invalid
;
error
draws the sentence explaining it under the field and announces it as it changes;
help
draws a sentence of guidance. Each sentence carries an id the control names in
aria-describedby
, the error first, so a reader who tabs away and back hears why the value was refused rather than only catching the announcement once.
labelledby
and
describedby
forward to the control as well, and your own
describedby
is read after the field's own two sentences.
type
is any text-like input type,
size
is
sm
,
md
or
lg
, and
mono
draws the value in the monospace face for a path, a selector or a colour.
name
,
autocomplete
,
disabled
,
readonly
and
required
forward to the control. The control carries the field's value as its default value too, so resetting a form the field sits in leaves the control saying what the field says rather than emptying it: the value is the field's own state, and putting a different value back is a write of
value
from the host. To focus the field and select its value from a host, call
selectValue(host, "all" | "stem" | "none")
or
focusField(host)
from
@jxsuite/ui/behaviors/textfield
.
clearable
adds a clear button inside the field, shown only while there is something to clear. Clicking it empties the value, puts focus back in the control and fires
input
and
change
from the field. Escape does the same from the keyboard. Both are keyed on
clearable
rather than on
type="search"
, because Firefox draws no clear button for a search field, and both go away on a
disabled
or
readonly
field so the reader is never handed a live control that empties a field they were refused permission to edit. On a single-line search field the element also cancels Enter, which would otherwise submit the form around it; on a textarea Enter still inserts a line.
grows
hands a multiline field's height to the content in it. Give
rows
as well to set a floor. On a browser without
field-sizing
, the field falls back to the height its
rows
asks for, or to the same three-row box a fixed field gets, so turning growth on never makes a field shorter.
The parts are
input
,
clear
and
clear-icon
,
error
and
help
, and the boxes
control-slot
,
clear-slot
and
help-slot
around them.
Checkboxes and switches
jx-checkbox
and
jx-switch
each wrap one native checkbox inside the
<label>
that names it, so a click anywhere on the label toggles it and Space works with no code. Use a checkbox for "this item is included" and a switch for "this setting is on". The switch carries
role="switch"
, which is what makes a screen reader say on and off instead of checked and unchecked.
{
"tagName": "jx-checkbox",
"$props": { "label": "Include drafts", "checked": { "$ref": "#/state/drafts" } }
}
Put the visible text inside the element instead of in
label
when it carries markup, and leave
label
unset: the slotted text names the box on its own, and setting both would name it twice.
indeterminate
is the checkbox's third state, for a box that stands for a set where some members are on. It is the reason this is an element at all: HTML has no
indeterminate
attribute, so a mixed box cannot be expressed in markup. Clicking a mixed box clears it and turns the box on, the way the platform does.
jx-switch
takes a
hint
, which becomes the title on the control. Both take
label
,
labelledby
,
describedby
,
name
,
disabled
and
size
. The native
change
and
input
bubble from the control, so
event.target.checked
reads as it would from a bare input. Both have the parts
control
,
input
and
label
, and on these two
control
is the
<label>
wrapping the input rather than the input itself.
Resetting a form these controls sit in leaves them where the reader left them, rather than snapping back. They are not form-associated yet, so a reset never reaches them; the control's default is kept in step with its live value so the two can never say different things. Putting a value back is a write from the host.
Number fields
jx-number-field
wraps a native number input, so the platform supplies the spinbutton role, the announced value, arrow-key stepping and the numeric keypad on a phone.
stepper
adds a pair of buttons. Holding Shift with an arrow key moves ten steps.
{
"tagName": "jx-number-field",
"$props": { "label": "Opacity", "min": "0", "max": "1", "step": "0.1", "stepper": true }
}
value
,
min
,
max
and
step
are strings, not numbers. That is deliberate: an empty string is a value a number cannot express, and a numeric prop would turn a cleared field into a zero. Read the number back with
event.target.valueAsNumber
, which is
NaN
when the field is empty.
label
,
labelledby
,
describedby
,
placeholder
,
name
,
disabled
and
size
work as they do on a text field. The two stepping buttons are named
Increase
and
Decrease
;
step-up-label
and
step-down-label
rename them for another language.
Stepping goes through the control's own
stepUp
and
stepDown
, so a value sits on the step grid measured from
min
. Adding the step yourself does not: with
min
0 and
step
0.3, stepping up from 0.5 gives 0.6, and 0.5 plus 0.3 gives 0.8.
The events are the platform's two names and nothing else. When the reader types, the native
input
and
change
bubble from the control untouched, so
event.target
is the live input with its
validity
and
form
; when the element writes the value itself, from a stepping button or a Shift-arrow, the same two names come from the element. The parts are
field
,
input
,
stepper
,
step-up
,
step-up-icon
,
step-down
,
step-down-icon
and the
stepper-slot
box around the buttons.
A number input throws away what it cannot parse, so a half-typed
1e
and a cleared field both read as an empty string. Before deleting a value because the field is empty, check
badInput
on the element, or its
data-bad-input
attribute. It is true while the reader is mid-way through typing something the control cannot represent yet.
Selects
jx-select
is a native
<select>
, not a rebuilt dropdown, so typeahead, scrolling the list to the current row, form participation and the accessibility tree all come from the platform. What the element adds is drawing: a row can carry its own font face, a colour swatch or a sample of a border style, and rows can sit under headings you can see.
{
"tagName": "jx-select",
"$props": {
"label": "Font",
"value": "Georgia, serif",
"groups": [
{
"id": "project",
"label": "This project",
"rows": [{ "value": "Georgia, serif", "label": "Georgia", "face": "Georgia, serif" }]
},
{
"id": "generic",
"label": "Generic",
"rows": [{ "value": "system-ui", "label": "system-ui", "description": "system" }]
}
]
}
}
A row is a
value
and a
label
, plus any of
description
,
disabled
,
face
,
swatch
,
line
,
weight
,
slant
,
variant
,
transform
and
decoration
.
face
sets that row's font,
swatch
fills a small block of colour beside it, and
line
draws a sample of a border style such as
dashed
. The last five set the row's words in a font weight, a font style (
slant
, since
style
is already an attribute), a font variant, a text transform and a text decoration, so a list of weights reads as its weights. They change how the words are drawn and never the words themselves. Give
options
instead of
groups
for a flat list, or give both: the ungrouped rows are drawn first.
value
is a string, and the empty string is one of its values rather than the absence of one, so a blank row can mean "inherit". Set
value
to something no row holds and the element adds a row for it instead of quietly selecting the first one.
Read the answer from
change
, the way you would from any select. The event comes from the inner control, so
event.target.value
is the row the reader picked.
label
,
labelledby
,
describedby
,
name
,
disabled
,
required
and
size
forward to the control as they do on a text field, and
invalid
,
error
and
help
draw and announce the same way. Give a
name
: it is also what satisfies the one HTML validity rule a page carrying this element otherwise trips, that a form field should have an id or a name.
You can also write rows yourself, as children: native
option
and
optgroup
elements, and an
<hr>
between them for a separator. Children are placed once, when the element is set up, so use them for rows that never change and
options
or
groups
for rows that do.
The parts are
control
, the
<select>
itself;
trigger
and
preview
, the closed face;
option
,
swatch
,
line
,
text
and
description
on each row;
group
and
group-heading
;
unlisted
, the stand-in row; and
error
,
help
and
help-slot
.
The drawing needs a browser with customizable select: Chrome or Edge 135 and later. In an older engine the control still works, still submits and still reads correctly, but the browser draws the list and the faces, swatches and group headings do not show.
Comboboxes and lists
jx-combobox
is a text field you can also pick from. It is one native
<input>
carrying
role="combobox"
over a
jx-listbox
of rows, and it reaches that list only by id, through
aria-controls
and
aria-activedescendant
. Use it where a select would be wrong because the answer is not always on the list.
{
"tagName": "jx-combobox",
"$props": {
"label": "Model",
"allowsCustomValue": true,
"value": "claude-sonnet",
"options": [
{ "value": "claude-sonnet", "description": "anthropic" },
{ "value": "gpt-4o", "description": "openai" }
]
}
}
A row is a
value
plus any of
label
,
description
,
disabled
,
face
,
swatch
,
line
,
weight
,
slant
,
variant
,
transform
and
decoration
, the same drawing channels a
jx-select
row carries.
value
is the text in the field, so a row's label is normally the same string it commits: a picker whose rows carry a hidden key is a
jx-select
.
The element does not filter.
options
is the list it will draw, not a corpus it searches. You already know how to rank your own rows, so re-answer
options
when you hear the
input
event and the list redraws.
A list opened by a gesture, the chevron or
Alt
with the down arrow, lands on the row whose value the field already holds, so a catalogue that arrived after the reader typed an id shows it as their row rather than as a list with nothing chosen. A plain arrow still enters at an end.
Typing highlights nothing.
After a keystroke nothing is selected, so Enter commits what the reader typed. Arrow onto a row first and Enter takes the row. That is what makes
allows-custom-value
mean something: with it set, anything the reader types stands, and the rows are suggestions. Without it the list is closed, and a value no row holds is put back to the last accepted one when the reader leaves the field. Either way the element says
change
exactly once per edit, and
event.target.value
is the value that stands.
The arrows open the list and move through it, wrapping and stepping over disabled rows. The chevron, and
Alt
with the down arrow, open the list on the row the field already holds, or on nothing when no row holds its value;
Alt
with the up arrow closes. Enter takes the highlighted row, or commits what was typed when no row is highlighted, and closes the list either way; so does a commit that arrives any other way. Tab takes whatever row is highlighted on the way out, and closes the list either way. Escape closes the list and stops there, so a combobox inside a dialog does not close the dialog with it. Home and End stay with the text cursor, where the reader is typing.
An empty list is not shown.
With no rows the chevron is not drawn and neither typing nor an arrow opens anything, so a field whose suggestions have not arrived yet is a text field until they do, and Escape reaches the dialog around it rather than a panel nobody could see. That is the shape of a catalogue you fetch: draw the field at once, hand it
options
when the listing lands, and whatever the reader typed while waiting stands. Studio's provider form is that field, with
allowsCustomValue
because a self-hosted model id is the normal case. Because the element's own
input
handler runs before yours, a host whose previous answer was empty sees the list on the keystroke after the one that produced rows; call
openList(host)
from
@jxsuite/ui/behaviors/combobox
once you have answered if you want it sooner.
label
,
labelledby
,
describedby
,
placeholder
,
name
,
required
,
disabled
and
size
work as they do on a text field, and so do
invalid
,
error
and
help
.
readonly
can be focused and selected but never opens the list, because every row is an edit.
autocomplete
defaults to
off
rather than to nothing, since the browser's own dropdown over a list the element is already drawing is two panels answering one question.
open
reports whether the list is showing and is not a way to show it. The parts are
field
,
input
,
toggle
and
toggle-icon
,
popup
,
list
,
option
,
error
,
help
and
help-slot
.
A list of your own
jx-listbox
and
jx-option
are the same list on their own, for a panel you are building yourself: a command palette, a slash menu, a picker with a search field above it. The listbox never takes focus. Something else owns the keyboard, and you say which row is current by giving the listbox the
active
id:
{
"tagName": "jx-listbox",
"attributes": { "id": "results" },
"$props": { "label": "Results", "active": "results-o1" },
"children": [
{
"tagName": "jx-option",
"attributes": { "id": "results-o0" },
"$props": { "value": "open", "label": "Open file", "description": "workspace" }
},
{
"tagName": "jx-option",
"attributes": { "id": "results-o1" },
"$props": { "value": "save", "label": "Save" }
}
]
}
That one id is the whole contract. Write it into the listbox's
active
and into your field's
aria-activedescendant
, and the listbox marks the row, clears the one before it, and scrolls the new one back into view. It keeps doing so when the rows themselves change, so a filter that rebuilds the list does not lose the highlight.
A row's words are its
label
, and
description
is a muted note at the end of it.
slot="icon"
takes a glyph and
slot="end"
takes one mark or chord. Each channel you leave out draws nothing.
jx-option
sends a bubbling
select
event whose detail is its
value
, so one listener on the panel hears every row. There is no default slot, on purpose: an option names itself from its contents, so a badge beside the words would join the row's name, and
label
is the only thing that does. The empty string is a legal
value
, a row meaning inherit or none, and never absence. A
disabled
row stays in the list and announced and dispatches nothing when clicked.
face
,
swatch
,
line
,
weight
,
slant
,
variant
,
transform
and
decoration
draw the row as its own preview, as on a
jx-select
row. A press on a row keeps the caret where it was, so a click never blurs the field that owns the keyboard. Its parts are
swatch
,
line
,
icon
,
label
,
description
and
end
;
jx-listbox
has none, because it is only the box the rows stand in.
Two parts are yours to write and the listbox draws them: a node carrying
part="group-heading"
above a run of rows, and a node carrying
part="empty"
for the sentence you show when nothing matched. What that sentence says is yours, because only you know what the reader was looking for.
A
jx-option
belongs in a
jx-listbox
and nowhere else. Put one inside a
<select>
or a
jx-select
and it renders, and the accessibility tree reads as though it worked, but the browser never counts it: it cannot be picked, the arrow keys skip it, and no
change
fires. The
custom-element-in-select
rule fails the document rather than letting it look right.
Field rows
jx-field
is a label, a control and a help line as one thing. It positions them and it names the control for you:
{
"tagName": "jx-field",
"$props": { "label": "Title", "description": "Shown in search results." },
"children": [{ "tagName": "jx-textfield" }]
}
Nothing here writes an id, and nothing writes
labelledby
. The field mints one and hands it to whatever you put inside it, which is the reason it is an element rather than three things you assemble each time.
required
draws a mark beside the label, drawn rather than added to the text so it stays out of the control's name. Marking the row does not make the control required: say so on the control as well, which is the element that validates.
invalid
turns the label and the help line red, and
warning
colours the label amber and leaves the sentence alone. Neither says anything to assistive technology;
aria-invalid
belongs on the control that holds the value.
span
gives the control the whole width instead of the two-column row, with the label above it.
description
is the sentence under the row, and the
help
slot is the richer version of it: anything slotted into
help
replaces the description rather than stacking with it. The control is pointed at the sentence only when there is one at mount, so a sentence that starts empty and fills later should be slotted rather than passed as
description
. The parts are
label
,
control
and
help
.
The naming happens once, over whatever is slotted when the row mounts. A control appended to the row afterwards lands outside the control part, unplaced and unnamed, so keep the control in the document and switch its state rather than adding it later.
A control the kit does not ship cannot be named this way, because the field hands the label's id to a property and a plain
<input>
has none. Name it yourself in that case, with
aria-labelledby
pointing at the field's label.
Dialogs
jx-dialog
is a native
<dialog>
opened modally, so the platform makes the rest of the page inert, answers Escape, and puts focus back where it was when the dialog closes.
headline
is its title and its accessible name; the body is whatever you put inside it. The buttons come from the labels:
confirm-label
(
OK
unless you say otherwise),
cancel-label
(
Cancel
), and
secondary-label
for a third answer such as Discard. An empty label removes that button.
{
"tagName": "jx-dialog",
"attributes": { "id": "delete-page" },
"$props": { "headline": "Delete this page?", "confirmLabel": "Delete", "destructive": true },
"children": [{ "tagName": "p", "textContent": "The file is removed from the project." }]
}
Open it from a button with no script:
{ "tagName": "jx-button", "$props": { "command": "--show", "commandfor": "delete-page" } }
. A
--close
command closes it. From code, call
showModal(host)
and
close(host)
from
@jxsuite/ui/behaviors/dialog
. The dialog dispatches
confirm
,
secondary
and
cancel
for its buttons and
close
when it has closed for any reason; after
confirm
it stays open until the host closes it, so a value the host refuses can keep the dialog up with its message.
destructive
draws the primary button in the negative variant.
dismissible
lets a click outside the dialog close it; Escape always does.
size
is
sm
,
md
or
lg
.
The dialog opens with its confirm button focused
, so a reader who answers the way people answer dialogs, with Enter, gets the primary action. A
destructive
dialog hands that focus to
cancel
instead, because the primary action there destroys something. Without this the browser focuses whichever button comes first in the markup, which for a Save, Discard and Cancel footer is Discard.
open
reports what the platform did, mirrored from the dialog's own
toggle
, and is not a way to open one. The parts are
dialog
,
header
,
headline
,
body
and
footer
;
confirm
,
secondary
and
cancel
, each with a
-label
part inside it and a
-slot
box around it; and
overlay-slot
, the box a popover opened from a control inside the dialog renders into, since a modal makes everything outside itself inert.
Show a menu
A menu is a native popover. Give it a name and a viewport position, fill it with rows, and show it with the platform's own call:
{
"tagName": "jx-menu",
"id": "actions",
"$props": { "label": "Actions", "x": 120, "y": 80 },
"children": [
{
"tagName": "jx-menu-item",
"$props": { "value": "copy" },
"children": [
{ "tagName": "span", "textContent": "Copy" },
{ "tagName": "kbd", "attributes": { "slot": "value" }, "textContent": "⌘C" }
]
},
{
"tagName": "jx-menu-item",
"$props": { "value": "paste", "disabled": true, "requires": "something on the clipboard" },
"children": [{ "tagName": "span", "textContent": "Paste" }]
}
]
}
import { openAt } from "@jxsuite/ui/behaviors/popover";
openAt(document.getElementById("actions"), triggerButton);
Each row is a
jx-menu-item
, and it dispatches a bubbling
select
event whose
detail
is its
value
. Listen for it on the menu. A disabled row stays in the list and shows its
requires
text as a tooltip. Set
destructive
on a row that deletes, and
checked
to
"true"
or
"false"
on one that toggles. A row with
haspopup
takes a child menu in its
submenu
slot, which opens on hover, on ArrowRight and on the chevron; the row itself still runs its own command. A row's other slots are
icon
before the words,
description
for one secondary line and
value
for a chord at the end; its parts are
icon
,
text
,
label
,
description
,
value
,
chevron
and
chevron-icon
. The click stops at the row, because a row inside a submenu is a descendant of the row that owns it and would otherwise activate both.
Arrow keys, Home, End and typing a letter move between rows. Enter and Space activate. Escape closes one level, and a click outside closes the whole stack.
A menu that hangs from a button sets
placement
, a CSS
position-area
value such as
block-end span-inline-end
, and opens it from that button: the platform places the menu against it, follows it as the page scrolls, and flips it when it would run off the screen. A submenu is placed beside its row the same way without being asked, and flips to the other side when it would leave the viewport. A menu with no
placement
is placed by
x
and
y
in viewport pixels, which is what a context menu at the pointer wants, and
floor
is the lowest edge it and its submenus may reach,
0
meaning the viewport: a menu opened from a rail that abuts a status bar sets it to the rail's bottom, so a tall submenu never runs down over the bar, and a menu with a floor is always placed by its coordinates.
open
follows the platform and is not a way to show the menu. The menu has no parts: it is the panel, and its rows are its children.
Focus goes back to the button that opened the menu, but only if you say which button that was. The browser restores focus relative to a popover's invoker, and it learns the invoker from a
popovertarget
attribute or from the argument
openAt
passes for you. A bare
showPopover()
names none, so the reader who closes the menu starts again from the top of the page.
Panels, tips and spinners
jx-popover
is a panel in the top layer. Give it an id, put anything inside it, and open it with a button:
{
"tagName": "jx-popover",
"attributes": { "id": "filters" },
"$props": { "label": "Filters" },
"children": [{ "tagName": "p", "textContent": "Anything at all." }]
}
{
"tagName": "button",
"attributes": { "popovertarget": "filters" },
"textContent": "Filters"
}
popovertarget
is one of a small set of attributes HTML gives to
<button>
and
<input>
and to nothing else, so a checker refuses it on any other tag. A kit button forwards it to the button inside itself, and Studio knows that, but a checker reading your project alone only knows the components your project defines. Write it on a
<button>
, or pass it to a kit button through
$props
rather than
attributes
, and it is correct everywhere.
The platform does the work: Escape closes it, a click outside closes it, Tab walks it in document order, and focus goes back to the button that opened it. Set
label
on any panel that holds controls, because a panel with no name announces no boundary when a reader enters it.
To open one from code, call
openAt(panel, trigger)
from
@jxsuite/ui/behaviors/popover
rather than
showPopover()
. Passing the trigger is what tells the browser where to send focus when the panel closes; without it a reader lands back at the top of the page.
close(panel)
closes it. The
open
prop reports what the platform did and is not a way to open one: writing it shows nothing.
Open it from a trigger, with
showPopover({ source })
or a button's
popovertarget
, and the platform places it:
placement
is a CSS
position-area
value,
block-end span-inline-end
by default, so the panel hangs below the trigger with its leading edges aligned;
block-end span-inline-start
hangs it from the trailing edge, and
block-start
opens it above. The panel follows the trigger as the page scrolls or resizes, flips to the other side when it would run off the screen, and with
match-width
is at least as wide as the trigger. Nothing is written on your trigger for this: the element you open a popover from is its anchor by the platform's own rule.
x
and
y
in viewport coordinates, and
floor
for the lowest edge it may reach, are the fallback: a panel opened from nothing (a context menu at the pointer), a panel given a
floor
, and a browser without CSS anchor positioning are placed there instead, and the kit keeps them inside the viewport itself. The gap between the panel and its trigger is the
--jx-popover-offset
token rather than a prop.
match-width
makes the panel at least as wide as whatever opened it, the way a select's list matches its trigger, measured once per toggle from the trigger the platform or
openAt
names.
arrow
draws a pointer at the panel's top edge; it is off by default because a panel placed by measured coordinates cannot promise the arrow lands on its anchor. The parts are
content
and
arrow
, and
content
is the scroll box: the panel itself never scrolls, because a scroll container clips whatever hangs outside its padding box and the arrow hangs above it. While an open or close transition is still running the host carries
data-jx-settling
, so measure a panel after that attribute goes rather than on the toggle.
jx-tooltip
is a tip for a control whose meaning is not written on it. It uses the
hint
mode, which is the only one that does not close an open menu, so a tip can explain a row of one:
{
"tagName": "jx-tooltip",
"attributes": { "id": "tip-save" },
"children": [{ "tagName": "span", "textContent": "Save this page" }]
}
Name it onto its control from the control's side, with
interestfor
where the browser supports it and
aria-describedby
either way. It is never focusable, it stays up while you hover it or hold focus, and Escape dismisses it. Those three together are what WCAG 1.4.13 asks of anything that appears on hover. You rarely write one for a button:
jx-button
and
jx-action-button
render their own from
hint
, ids included, and
mintHintId
from
@jxsuite/ui/behaviors/tooltip
is the counter they mint those ids from, should an element of yours need the same.
Both wirings work on every engine. Where the browser has no interest invokers the tip binds the pointer and focus of the control that declares
interestfor
at it itself, and
for
, the id of a control, is the other way round: the tip names its control. Either is read once, at mount; a tip wired up later is bound with
bindTooltip
from
@jxsuite/ui/behaviors/tooltip
. The desktop app is one of those engines: its Chromium is 147, and interest invokers shipped in 152, so Studio's own chrome runs the bound path until that bump lands. On it a bound tip costs a handful of listeners on its control and the tip, and Escape is one shared listener per document rather than one per tip.
delay
is how long the pointer must rest before the tip appears, in milliseconds, and keyboard focus never waits; on an engine with interest invokers the wait is the platform's own
interest-delay-start
, and this number is not consulted. The tip hangs under its control, or above it when there is no room below, and never over it: the browser places it against the control it was shown from, follows the control as the page scrolls, and flips it when it would run off the screen;
placement
is a CSS
position-area
value if you want another side. On a browser without anchor positioning the tip places and flips itself, and
x
,
y
and
flipped
are what it writes when it does, and what a host that places a tip itself sets instead.
arrow
draws a pointer on the edge facing the control, and
arrow
is its one part. The pointer follows the tip: when the browser flips the tip while it is up, because the page scrolled or the window shrank, the pointer moves to the other edge with it.
jx-spinner
says work is happening. Leave
value
empty for the usual case, where nothing knows how far along it is; give it a percentage to draw a ring instead. It is a string, not a number, because an empty string is a value a number cannot express and a numeric prop would read "unset" as zero:
{ "tagName": "jx-spinner", "$props": { "label": "Loading pages" } }
Give it a
label
when it stands alone, and leave the label off when it sits inside a button that is already named: without one the spinner hides itself from screen readers, so the button is not announced twice. A reader who asks for reduced motion gets a slower spin rather than a stopped one, because a stopped spinner reads as a hang.
It draws in the colour of the text around it, so a spinner inside a button is visible on every variant without being told. Override
--jx-spin-color
and
--jx-spin-track-color
on the element or an ancestor to change that.
size
is
sm
,
md
or
lg
, and
md
matches the kit's icon size so a spinner swapped in for a glyph does not move the row. The parts are
glyph
and
glyph-icon
.
Toasts
A toast reports one outcome beside the reader's work: a glyph, a line of text, at most one control that undoes or retries it, and a dismiss button that is always there.
jx-toast
is the message and
jx-toast-host
is the stack it lives in.
{
"tagName": "jx-toast-host",
"children": [
{
"tagName": "jx-toast",
"$props": { "open": true, "variant": "negative", "timeout": 8000 },
"children": [
{ "tagName": "span", "textContent": "Could not reach the deploy service." },
{
"tagName": "jx-button",
"attributes": { "slot": "action" },
"$props": { "size": "sm", "quiet": true },
"children": [{ "tagName": "span", "textContent": "Retry" }]
}
]
}
]
}
variant
is
info
,
positive
,
negative
or
warning
. It picks both the accent along the leading edge and the glyph, so the severity survives greyscale and a forced-colours theme. It is not announced, so write the message so that it says what happened on its own.
open
is yours to write: a toast appended without it draws nothing, so arriving and appearing stay two decisions.
timeout
is milliseconds and
0
is sticky, which is the default. The element writes
open
back to false when it retires itself and dispatches
close
, whose
detail.reason
is
timeout
,
dismissed
or
action
. Closing a toast from your own code with
open = false
dispatches nothing, because you already know.
Anything in the
action
slot is the one thing a reader may do about the message. Using it closes the toast and reports
action
. The click is not stopped, so your own handler runs first and a listener above the toast hears it too. The dismiss button is always drawn and takes its name from
dismiss-label
.
A toast never takes the keyboard, and its clock stops the moment you reach it. While the pointer is over a toast, while focus is inside it, or while anything in the same stack is being read, no toast in that stack is counting down, and the clock then resumes with the time that was left rather than starting over. So a control the reader has reached cannot expire under their hand, and an older message cannot vanish out from under someone answering a newer one.
Press
F8
to put the keyboard in the stack.
The stack sits at the end of the document, where Tab reaches it last, so the host offers a key instead. Focus goes to the first control in the first open toast, and Escape gives it back to wherever it came from. Dismissing the toast you are standing in gives it back the same way. Set
hotkey
to another key, or to the empty string if your application has a command of its own for this.
jx-toast-host
is the live region: the toasts inside it carry no role of their own, so a message is announced once.
live
is
polite
by default,
assertive
for the rare message that cannot wait, and
off
for an application that already announces outcomes somewhere else. Role and attribute move together:
polite
writes
role="status"
,
assertive
writes
role="alert"
, and
off
writes neither.
label
names the region, and
placement
pins the stack to
bottom-end
,
bottom-start
,
top-end
or
top-start
. The order is the order you write, in every corner, so what a reader hears, what they tab through and what they see agree. Off a host, a toast is an ordinary box in the flow, drawn and not announced.
A toast's parts are
icon
and
glyph
,
message
,
dismiss
, and the
action-slot
box around the control. The host has none.
Tabs
jx-tabs
is a real tab strip: the platform's roles, the arrow keys, and one stop in the tab order for the whole strip.
{
"tagName": "jx-tabs",
"$props": { "label": "Inspector", "selected": "style" },
"children": [
{
"tagName": "jx-tab",
"$props": { "value": "content", "label": "Content", "panel": "p-content" }
},
{ "tagName": "jx-tab", "$props": { "value": "style", "label": "Style", "panel": "p-style" } }
]
}
Give it a
label
. A tab strip with no name is announced as a bare group, and nothing will tell you: the naming rules stand down for an element that carries its role from a binding, so no checker sees the omission.
Each
jx-tab
names its panel with
panel
, and each
jx-tab-panel
points back with
labelledby
. Nothing checks that pairing, so mint both ids from one key. A panel takes
active
to say it is the showing one; the others are hidden rather than removed, so the ids their tabs name stay resolvable. A showing panel takes one tab stop, the price of a panel whose whole content may be static text. Arrow keys move along the strip and wrap, Home and End jump to the ends, and Tab enters and leaves in one press.
activation
decides whether moving the caret selects as it goes, which is the usual behaviour, or waits for Enter or Space, which is right when selecting a tab is expensive.
orientation
is
horizontal
or
vertical
; it chooses which arrow pair moves the caret and which edge carries the strip's rule and the selected mark. The strip fires
change
with the new value, and
selected
is already written when it does. The strip is the one writer of every tab's
selected
, so move the selection by writing the strip's, never a tab's.
closable
adds a close button and Delete closes the focused tab; both dispatch
close
with the tab's value. Enter and Space on the close button close it too, rather than selecting the tab it sits in.
dirty
draws the unsaved dot, which is hidden from assistive technology: a reader is told about unsaved work by the page. Write a tab's
label
as an attribute: a property write does not reflect, and a strip's selectors read the attribute.
A tab takes three named slots, so you can decorate one without rebuilding the strip.
icon
draws before the label;
status
and
actions
draw after it and before the tab's own dirty dot and close button.
{
"tagName": "jx-tab",
"attributes": { "value": "post", "label": "hello.md", "closable": "" },
"children": [
{ "tagName": "span", "attributes": { "slot": "status" }, "textContent": "Draft" },
{
"tagName": "jx-action-button",
"attributes": { "slot": "actions" },
"$props": { "icon": "eye", "size": "sm", "label": "Preview hello.md", "tabindex": "0" }
}
]
}
Pick between the two by what a click should do.
status
is for a mark about the document, such as a pill, a count or a sync state, and a click on a mark selects the tab like a click anywhere else on it.
actions
is for a control, such as a pin or a lock, and a click inside it stops there, so pressing the control never also moves the selection. There is no default slot: a tab's words are its
label
.
Always give a tab a
label
once you slot anything into it. The label is the tab's accessible name, so nothing you slot in can join it and each control you add keeps announcing its own name. Leave the label off and the tab falls back to naming itself from its content, which means a tab holding only a pin button is announced as "Pin".
Two things stay yours. A control in
actions
should be in the tab order only while its tab is the current one, the way the close button is: bind its
tabindex
to the selection, which is what
jx-action-button
takes a
tabindex
property for. And a mark that says nothing useful to a screen reader is yours to hide with
aria-hidden
, because only you know whether it is decoration.
A tab's parts are
icon
,
label
,
status
,
actions
,
dirty
and
close
with
close-icon
, plus the
dirty-slot
and
close-slot
boxes. The strip and the panel have none.
Sections
jx-accordion-item
is a native
<details>
with a heading you can style:
{
"tagName": "jx-accordion-item",
"$props": { "label": "Advanced", "open": true },
"children": [{ "tagName": "p", "textContent": "Anything." }]
}
Give several of them the same
name
to make the group exclusive, so opening one closes the rest. That is the platform's own behaviour and needs no script.
label
is required. It is written as the summary's accessible name rather than left to name-from-content, because the actions row sits inside the summary and would otherwise be read as part of the section's name. Two slots decorate the summary:
heading
for a mark beside the label, and
actions
for a control that stays visible while the section is shut. A click on a control in
actions
runs the control and does not toggle the section; a click on the row's own empty space toggles nothing either. An inert mark belongs in
heading
, because a non-interactive element slotted into
actions
still toggles the section.
level
, a number, opts the label into heading semantics so a long panel can be skimmed by heading navigation. The cost is that the label is announced twice when reading linearly, once as the heading and once as the disclosure, which is why it is opt-in.
open
is two-way: the platform writes it when the reader opens the section and you may write it to open or close one. Written as a property it does not reflect onto the host's attribute, so read it as a property or from
toggle
rather than with
getAttribute
.
Put them in a
jx-accordion
to draw them as one stack, with a hairline between sections and none above the first visible one. Its
multiple
prop,
true
by default, is advisory: it writes down whether more than one section may stand open, and the platform's own switch for exclusivity is
name
on each section, which only the section may carry. Set
multiple
to
false
and make it true by giving every item the same
name
.
The element fires
toggle
when a section opens or closes, and that event stops at the element. A native
toggle
does not travel up the page, so a section inside a menu or a panel cannot close the thing around it by opening. The parts are
details
,
summary
,
marker
and
marker-icon
,
label
,
heading
,
actions
and
body
; the stack has none.
Button groups
jx-action-group
gives a row of
jx-action-button
s the right role and one tab stop:
{
"tagName": "jx-action-group",
"$props": { "selects": "single", "label": "Text alignment", "compact": true },
"children": [
{
"tagName": "jx-action-button",
"$props": { "icon": "text-align-left", "label": "Left", "checked": "true" }
},
{
"tagName": "jx-action-button",
"$props": { "icon": "text-align-center", "label": "Centre", "checked": "false" }
}
]
}
selects
decides what the row is:
"none"
is a toolbar of separate actions,
"single"
is a set of choices where one wins, and
"multiple"
is a set of independent switches. Arrow keys move within the row and Tab leaves it, so a toolbar of ten buttons costs one tab stop rather than ten.
compact
joins the buttons into one segmented control, and it reaches only the buttons that are the group's own children, so a nested group keeps its own seam.
label
names the group, and a reader entering it hears the name;
orientation
is
horizontal
or
vertical
, and it picks the arrow pair and turns the row. The group has no parts.
The group chooses the pattern and the keyboard and does not own the selection. In
"single"
it clicks the button the caret lands on and lets you decide what that means, so the value stays in your state.
For a single-choice row, set
checked
to
"true"
or
"false"
on each button rather than
selected
, and do not set
toggles
. A button that both announces a chosen state and flips itself would fight the host that owns the value, so the element refuses the combination.
Toolbars
jx-toolbar
is a row of controls with one tab stop. Reach for it when the row holds more than buttons: a text field, a divider, a plain
jx-button
, a count at the far end.
{
"tagName": "jx-toolbar",
"$props": { "label": "Grid actions" },
"children": [
{ "tagName": "jx-button", "$props": { "label": "Save", "variant": "accent" } },
{ "tagName": "jx-action-button", "$props": { "icon": "arrows-clockwise", "label": "Refresh" } },
{ "tagName": "jx-divider", "$props": { "orientation": "vertical" } },
{ "tagName": "jx-textfield", "$props": { "type": "search", "label": "Filter rows" } }
]
}
label
is what a screen reader announces when the reader enters the row, so give every toolbar one.
orientation
chooses which arrow pair moves between the controls, and turns the row through a quarter turn.
Arrow keys move along the row and wrap at both ends, Home and End reach the ends, and Tab leaves the whole toolbar. A row of any length costs one tab stop.
A text field in the row keeps the arrow keys while its caret still has text to move through. Press the same arrow again at the end of the text and the caret leaves the field for the next control, so you arrow in, type, and arrow out with one key. Home and End inside a text field always belong to the field.
A
jx-color-field
in the row is one stop, and the stop is its swatch. Arrow onto it and
Enter
opens the picker, which holds everything a colour needs. The text box, the eyedropper and the system colour well beside it leave the tab order while the field sits in a toolbar, so there they are reached with a pointer; in a form each has its own stop again.
Two kinds of control are not moved between by the toolbar's arrows, and each keeps a tab stop of its own instead. Anything that runs its own arrow keys is left alone, such as a
jx-action-group
or a
jx-tabs
: two roving carets over one row would disagree about which control is current. So is anything whose arrow keys are already spoken for, such as a
jx-select
, a range, a spin button or a
jx-combobox
, because walking the caret past one would rewrite what somebody had chosen or open a list they had not asked for.
There is no overflow menu, by decision. A row that will not fit is a row to shorten. Where a list of what is out of view is genuinely needed, the host measures it and opens a
jx-menu
of its own commands, which is what the editor's tab strips do.
Which of the two to use:
jx-action-group
for a row of
jx-action-button
s that share one look, and
jx-toolbar
for a row of mixed controls. A group may stand beside a toolbar, never inside one.
Trees
jx-tree
is a
role="tree"
over a FLAT list of
jx-tree-item
children. Each row carries its own
level
,
posinset
and
setsize
rather than sitting inside a nested structure, because a long tree draws a window onto its model and a windowed row has no ancestors in the page to count.
{
"tagName": "jx-tree",
"$props": { "label": "Project files", "current": "src/index.json" },
"children": [
{
"tagName": "jx-tree-item",
"$props": {
"value": "src",
"label": "src",
"level": 1,
"posinset": 1,
"setsize": 2,
"expanded": "true"
}
},
{
"tagName": "jx-tree-item",
"$props": {
"value": "src/index.json",
"label": "index.json",
"level": 2,
"posinset": 1,
"setsize": 1
}
}
]
}
current
is the caret: the one row that holds the tree's tab stop, so
Tab
enters and leaves the whole tree in one press. Write the tree's
current
to move it; never a row's own caret.
expanded
is
"true"
or
"false"
for a row with children and
""
for a leaf, which is how the keyboard tells "closed" from "cannot open".
posinset
and
setsize
describe the FULL set, not the drawn one, and zero writes neither, which asks a reader's software to count the page instead.
The arrows walk the rows and deliberately do not wrap, which is where a tree differs from a menu or a tab strip: losing your place in an outline is worse than one extra key. Home and End reach the ends, ArrowRight opens a closed row and steps into an open one, ArrowLeft closes an open one and otherwise steps out to its parent, Enter activates and a printable character moves to the next row starting with it. Every other key reaches you untouched, which is what keeps cut, paste, rename and delete reachable from a focused row.
The tree reports what the reader meant and never resolves it.
select
carries
{ value, mode, anchor }
, where
mode
is
replace
,
toggle
or
range
, and the selection set is yours to hold: a range names every row between two of them, and under windowing those are the rows the page does not have.
expand
carries
{ value, expanded }
and says the state a row should be PUT INTO, so the twisty and the arrow keys arrive by one route.
activate
is
Enter
or a double click.
multiple
on the tree says more than one row may be selected, as
aria-multiselectable
, and it gates the modifiers: with it off every
select
is
replace
however many keys the reader holds, so a host that never asked for multi-select is never handed a
toggle
.
anchor
is the row a
Shift
range extends from; the tree writes it on every
replace
and
toggle
, and you may seed it to restore a session. Write
selected
on each row yourself, since a selection is a set and a windowed tree draws part of it. A row writes
aria-selected
only when it is selected, because a
false
on every row is what tells assistive technology a tree is multi-select, and that answer belongs on the tree.
A row is all a
jx-tree-item
is: it draws itself and knows nothing about its neighbours.
disabled
keeps the row drawn and counted, so its siblings do not renumber, but the caret steps over it and a click on it does nothing.
cut
draws a row already lifted to the clipboard, faded and invisible to assistive technology, because cut and paste is the keyboard alternative to dragging and the page announces the move.
grip
draws the drag handle, a plain hidden span rather than a control: it is the grab affordance for a pointer drag and never a second way to move the row. A row's three slots are the tab's:
icon
before the name,
status
for a mark, and
actions
for a control whose click stops there. Give every row a
label
once anything is slotted, for the reason a tab needs one. The tree's parts are
pad-top
and
pad-bottom
, the two spacers; a row's are
twisty
and
twisty-icon
,
icon
,
label
,
status
,
grip
with its
grip-slot
box, and
actions
. Each row also carries
data-value
, which is what a drag-and-drop library attaches to.
A tree that draws a window
Set
padtop
and
padbottom
to the pixels of scroll you are reserving above and below the drawn rows, and the tree knows it is showing part of a model. Two things then change, and both become messages to you.
A caret move that runs off either end has no row to land on, so the tree raises
move
with
{ from, key }
and performs nothing. A letter raises
typeahead
with
{ from, char }
for every printable character, not only for the ones no drawn row answers: a search over a slice always finds something, and what it finds is whichever of the dozen painted rows happened to start with that letter. Answer both against your own model, then scroll, repaint, and write the tree's
current
.
Answering means moving the caret, and it does not mean moving the keyboard. The tree keeps that half. It focuses the revealed row once the roving
tabindex
has reached it, which is a moment only the tree can see: the row you draw is in the page, answering to its
value
, before its own
tabindex
is written, and focusing it any earlier does nothing at all and says nothing about it.
Write
current
for your own reasons and the caret moves alone, which is what a canvas selection or a jump from a search result should do. A reader who clicks or types somewhere else while you are scrolling keeps the keyboard too: the tree gives up the move rather than pulling focus back out of wherever they went.
With both pads at zero the drawn rows ARE the model. The ends of the slice are the ends of the tree, the arrows clamp there, letters are resolved in the tree itself, and neither event is raised.
Splitters
jx-split
is the divider between two panes, and it is a control rather than a drag handle: it takes a tab stop, announces where it sits, and moves with the arrow keys.
{
"tagName": "div",
"style": {
"display": "grid",
"gridTemplateColumns": "minmax(0, undefinedfr) auto minmax(0, NaNfr)"
},
"children": [
{ "tagName": "div", "textContent": "Navigator" },
{
"tagName": "jx-split",
"$props": {
"label": "Navigator and editor",
"value": { "$ref": "#/state/share" },
"gap": 120
},
"oninput": { "$ref": "#/state/onShare" }
},
{ "tagName": "div", "textContent": "Editor" }
]
}
value
is the share of the box that goes to the side before the splitter, from 0 to 1, so it is a ratio and not a pixel count. That is what lets the same layout survive a window resize: the two sides keep their proportions instead of one of them keeping a width. Listen for
input
while the reader is moving it and for
change
when they let go, and save on the second one.
gap
is the one number you give in pixels: the smallest either side may become. The element measures the box it divides at the start of every gesture and converts the gap against that measurement, so nothing on your side has to watch for a resize.
min
and
max
are ratios, and both are honoured: the tighter of the two wins at each end.
Arrow keys move the splitter one
step
at a time along its own axis,
Shift
with an arrow takes a
largeStep
, and
Home
and
End
go as far as the gap allows.
Enter
collapses the split and a second press restores it to where it was. A double click does the same thing with the pointer, which is what gives a reader who cannot drag a way to reach both positions.
collapse
is the value both go to; its default sits below every legal share and clamps to
min
, so collapsing means going to the floor unless you say otherwise. A keystroke is a move and a commit, so
input
and
change
both fire for it.
Every
input
and
change
carries the modifier keys that step was made with as its
detail
:
{ altKey, ctrlKey, metaKey, shiftKey }
. The element gives none of them a meaning beyond
Shift
's larger step; a host that snaps the value onto positions of its own reads them with
splitModifiersOf(event)
from
@jxsuite/ui/behaviors/split
and decides which key is the way past the snap. A plain
input
you dispatch yourself reads as no modifiers.
orientation
names the splitter rather than the direction it travels, which is what ARIA means by it: the default
vertical
is the upright line between two side by side panes, dragged left and right, and
horizontal
is the flat line between two stacked boxes, dragged up and down.
label
is its accessible name, and every splitter needs one: a separator is named by nothing else, no checker asks, and a reader arriving on an unnamed one is told they have landed on a separator and nothing more. Name it after the two things it divides.
disabled
keeps the box and the announcement and drops the tab stop, because a layout a reader cannot explain is worse than one they cannot change. While a drag is live the host carries
data-dragging
, so the splitter stays lit for a pointer that has left it. It has no parts: the element is the whole handle.
Colours
jx-color-field
is the whole control: a swatch that opens a picker, a text box for the value, and the doors to the system's own picker.
{
"tagName": "jx-color-field",
"$props": { "label": "Background", "value": "#3b82f6", "alpha": true }
}
label
says what the colour is for. Every control inside takes its name from it, so the text box is "Background", the swatch button is "Pick Background", and the screen picker is "Pick Background from the screen".
The element writes hex by default. Set
format
to
"oklch"
and it writes
oklch()
instead. That decides what it writes, never what it reads: a reader may type hex,
rgb()
or
oklch()
into the text box either way, and a half-typed value is only refused once they commit it.
A value the field cannot take apart is kept rather than refused. Hand it
var(--brand-accent)
or a named colour and it holds the string, shows it in the text box, and still draws it in the swatch, because the browser resolves it. Only the picker's sliders are stale, and the first thing the reader moves replaces the token with a literal.
When the browser cannot resolve it either, because the token is defined in a page other than the one drawing the field, set
resolved
to the colour the reference stands for. The swatch draws that, the picker opens on it, and the value stays the reference. Clear it when the value stops being a reference; empty, the swatch draws the value itself.
alpha
adds the opacity track to the picker and the alpha channel to the value. Leave it off and every value the field writes is opaque, so a field feeding a property with no alpha cannot be handed one by accident.
system
and
eyedropper
control the two doors.
system
is a native
<input type="color">
, which opens whatever picker the operating system has.
eyedropper
is the screen picker, and its button appears only on engines that have the API, so you never get a control that does nothing when you press it. Turn both off where the colours must come from the project's own palette.
labelledby
names the text field from a visible label you own; the other controls keep their composed names, because a label naming the row does not name the button inside it.
size
and
disabled
forward to every control inside. The empty string means no colour has been chosen and draws the slashed chip rather than black. The parts are
row
;
control
, the swatch button, with
preview
inside it;
text
, the field;
dropper
and
system
with their
-slot
boxes;
picker
, the
jx-popover
itself; and inside it
panel
,
area
,
hue
,
opacity
with
opacity-slot
, and
tokens
. The swatch button is
control
because it is the one stop a roving container focuses: it is the opener, so Enter on it reaches the whole picker, and it is the control the element always draws.
tabindex
is a property on this element as on the buttons, and while it is set the text field and the two doors step out of the tab order, so a toolbar that was told this element is one stop finds exactly one.
Listen for
input
and
change
on the element. They come from the element itself whichever of the five controls the reader touched, so
e.target.value
is always the colour:
{
"tagName": "jx-color-field",
"$props": { "label": "Accent", "value": { "$ref": "#/state/accent" } },
"onchange": {
"$prototype": "Function",
"body": [
{
"operator": "=",
"target": { "$ref": "#/state/accent" },
"value": { "$ref": "event#/target/value" }
}
]
}
}
A palette in the picker
Anything slotted into
tokens
is drawn under the sliders, and choosing from it sets the field's value:
{
"tagName": "jx-swatch-group",
"attributes": { "slot": "tokens" },
"$props": { "label": "Project palette", "columns": 5, "value": "#0ea5e9" },
"children": [
{ "tagName": "jx-swatch", "$props": { "color": "#0ea5e9", "label": "Sky" } },
{ "tagName": "jx-swatch", "$props": { "color": "#22c55e", "label": "Green" } }
]
}
Swatches on their own
jx-swatch
is a colour chip that is a real button. Give every one a
label
: that is its accessible name, and without it a reader hears the hex code one character at a time. Set
value
when the swatch stands for a token rather than for the literal colour, and the
select
event carries that instead.
color
is any CSS colour the page can resolve, and the empty string is the no-colour chip, a slashed square, because a blank square in a palette reads as white.
checked
and
selected
are different things:
checked
,
"true"
or
"false"
, makes the swatch one radio of a group and is what a reader is told, and
jx-swatch-group
is its single writer;
selected
draws the chosen ring and the tick without announcing anything, for a swatch a host has chosen outside a group. Setting one never sets the other.
size
is
sm
,
md
or
lg
, and the chip grows while the button's target never shrinks below the kit's control height.
disabled
cannot be chosen or focused. Slotted text sits beside the chip as a caption, and the label still names the swatch, so the two must agree. The parts are
control
,
chip
,
mark
with its
mark-slot
box, and
label
.
jx-swatch-group
makes a set of them a radio group with one tab stop. Either arrow pair moves between swatches and chooses the one it lands on, Home and End go to the ends, and disabled swatches are stepped over. The group owns the selection: write its
value
and it moves the swatches, and listen for
change
on the group rather than for
select
on a swatch.
label
names the group, and every group needs one, since a radio group takes no name from its members: "Theme palette" and "Recent colours" are two different questions and a reader hears only this.
columns
lays the swatches out on a fixed grid. Leave it at 0 and they wrap on their own. The group has no parts.
The picker's own parts
jx-color-area
is the saturation and brightness square, and
jx-color-slider
is a hue or alpha track. Use them directly when you are building a picker of your own rather than using
jx-color-field
.
{
"tagName": "jx-color-area",
"$props": { "label": "Accent colour", "hue": 265, "saturation": 72, "brightness": 88 }
}
The square holds two hidden range inputs, one for each axis, so it is fully operable from the keyboard. Left and right move the saturation, up and down move the brightness, Home and End go to the ends of the axis you are on, and holding Shift moves ten steps at a time. Every colour the pointer can reach is reachable this way, and a single click sets the colour outright, so nothing here needs a drag.
The square never writes the hue. Give it one from a
jx-color-slider
beside it, and dragging into the grey corner leaves the hue where the reader put it.
label
names the square, and it is required: the square is a group holding two sliders, a group is named by nothing it contains, and no checker asks.
x-label
and
y-label
name the two axes,
Saturation
and
Brightness
unless another language needs other words.
brightness
is measured upward from the bottom edge, so 100 is the top.
step
is the grid both axes move on, for the arrow keys and for the snap a pointer drag is put through, so the thumb and the announced value cannot disagree mid-drag.
disabled
forwards to both ranges. The parts are
track
,
x
and
y
, the two ranges, and
thumb
.
{
"tagName": "jx-color-slider",
"$props": { "label": "Hue", "channel": "hue", "value": { "$ref": "#/state/hue" } }
}
channel
is
"hue"
or
"alpha"
. It chooses the gradient, the units the value is announced in, and the top of the range, so a hue track runs to 360 and an alpha track to 100 without your writing
max
. An alpha track fades from whatever you pass as
color
, and from
currentColor
when you pass nothing.
min
and
max
cut the track down to a band, and
max
at
0
means unset rather than a top of zero, since a track that ends at zero has nowhere to move.
step
is the grid the value moves on, for the platform's own arrows and for the ten-step
Shift
arrow the element adds; a plain arrow, Home, End and the Page keys are the platform's.
label
or
labelledby
is required, because a slider takes no name from its contents.
disabled
forwards to the range. The parts are
track
and
input
.
Both elements say
input
and
change
from themselves rather than from the range inside, so read
e.target.saturation
and
e.target.brightness
from a square and
e.target.value
from a track.
Colour maths lives in
@jxsuite/ui/color
: hex,
rgb()
and
oklch()
parsing, sRGB to OKLCH and back, WCAG relative luminance and contrast ratio, and
preferredInk
, which answers with the black or white that can actually be seen on a colour. The kit uses that last one for a swatch's own boundary and tick, and you can use it for anything you draw on a colour a user chose.
Open the kit in Studio
The kit is a Jx project. Open
packages/ui/project.json
in Studio to see every element on the canvas, with one stylebook page per element showing its variants and states.
Studio's own chrome is a Jx project too:
packages/studio/project.json
, whose documents are the surfaces under
src/surfaces/
. Open either project and Studio edits itself. Save a component under
components/
and every part of the shell that draws that element is re-mounted with the new definition, and every open canvas is told the new definition and drawn again; save a surface under
src/surfaces/
and the roots drawn from it are re-mounted, keeping the host state they had. A toast says how many places changed, and a warning says why a save was not applied: a document whose
tagName
disagrees with its file name, or a surface file no adapter mounts. The lane acts only on those two paths, so saving any other project's files does nothing to the shell.
On the canvas, a kit element is drawn from the project's own files, and its behaviour is fetched the first time a page needs it, one small chunk per behaviour, so a page that uses no kit element loads none of them. Design mode shows a bound prop as its binding text, which is the canvas's rule for every bound value; switch to Preview to work a stylebook page's controls.
A save writes the file in the layout the repository keeps it in. The surfaces and the kit components are formatted by the pre-commit hook, with short objects such as
"attributes": { "part": "bar" }
on one line, and Studio
preserves that layout
when it writes, so
git diff
after a save shows the edit and nothing else.
An edit made outside Studio, in your editor, reaches the shell the way it always did: on the next reload. The lane re-mounts from the document Studio just wrote, never from the disk.
Related
Embedding the runtime in your app for how a host mounts documents and passes state in.
Lists and iteration for the keyed rows a surface is built from.