rename web/ to admin/, along with the web-named build and cli identifiers
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* The single-choice picker (milestone 23, ruling 4), replacing every native
|
||||
* `<select>` in the app.
|
||||
*
|
||||
* RAC composes a Select out of Label, Button, SelectValue, Popover, ListBox and
|
||||
* ListBoxItem; those parts are the Select, not extra components adopted beyond
|
||||
* ruling 4's scope, and nothing outside this file imports them.
|
||||
*
|
||||
* Keys are strings because a `<select>`'s value was a string. A call site that
|
||||
* models an id converts on both edges.
|
||||
*/
|
||||
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Button, Label, ListBox, ListBoxItem, Popover, Select as AriaSelect, SelectValue } from "react-aria-components";
|
||||
import { colors } from "./tokens.stylex";
|
||||
import { styles as shared } from "./styles";
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
options: readonly SelectOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** The visible label. Omit it only when `aria-label` names the control. */
|
||||
label?: string;
|
||||
"aria-label"?: string;
|
||||
/**
|
||||
* `field` matches a full-width form input, `compactField` the smaller one a
|
||||
* dialog uses, `inline` a control sitting in a row of other controls.
|
||||
*/
|
||||
variant?: "field" | "compactField" | "inline";
|
||||
}
|
||||
|
||||
const styles = stylex.create({
|
||||
root: {
|
||||
display: "block",
|
||||
},
|
||||
label: {
|
||||
display: "block",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
trigger: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.5rem",
|
||||
textAlign: "left",
|
||||
cursor: "pointer",
|
||||
},
|
||||
compact: {
|
||||
marginTop: "0.25rem",
|
||||
width: "100%",
|
||||
},
|
||||
/** Explicit, so RAC's default `react-aria-SelectValue` class does not land. */
|
||||
value: {
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
chevron: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
popover: {
|
||||
width: "var(--trigger-width)",
|
||||
maxHeight: "16rem",
|
||||
overflowY: "auto",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
color: colors.text,
|
||||
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
|
||||
},
|
||||
listBox: {
|
||||
outlineStyle: "none",
|
||||
paddingBlock: "0.25rem",
|
||||
},
|
||||
item: {
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.375rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
cursor: "pointer",
|
||||
},
|
||||
/**
|
||||
* The inverted row is the listbox convention, and the ring is the milestone-9
|
||||
* floor; an option gets both. RAC focuses the option's own DOM node, so the
|
||||
* shared `:focus-visible` ring does apply here — it is drawn inset because an
|
||||
* option flush against a scrolling popover clips an outset one, and recoloured
|
||||
* because the focus token is the same blue this row just painted behind it.
|
||||
*/
|
||||
itemFocused: {
|
||||
backgroundColor: colors.primary,
|
||||
color: colors.primaryText,
|
||||
outlineColor: { default: null, ":focus-visible": colors.primaryText },
|
||||
},
|
||||
itemSelected: {
|
||||
fontWeight: 600,
|
||||
},
|
||||
});
|
||||
|
||||
export default function Select({ options, value, onChange, label, "aria-label": ariaLabel, variant = "field" }: Props) {
|
||||
const base = variant === "field" ? shared.input : shared.smallInput;
|
||||
const block = variant === "compactField" ? styles.compact : null;
|
||||
return (
|
||||
<AriaSelect
|
||||
aria-label={ariaLabel}
|
||||
value={value}
|
||||
onChange={(key) => onChange(String(key ?? ""))}
|
||||
{...stylex.props(styles.root)}
|
||||
>
|
||||
{label !== undefined && <Label {...stylex.props(styles.label)}>{label}</Label>}
|
||||
<Button className={() => stylex.props(base, block, styles.trigger, shared.focusRing).className ?? ""}>
|
||||
<SelectValue className={() => stylex.props(styles.value).className ?? ""} />
|
||||
<span aria-hidden="true" {...stylex.props(styles.chevron)}>
|
||||
▾
|
||||
</span>
|
||||
</Button>
|
||||
<Popover className={() => stylex.props(styles.popover).className ?? ""}>
|
||||
<ListBox {...stylex.props(styles.listBox)}>
|
||||
{options.map((option) => (
|
||||
<ListBoxItem
|
||||
key={option.value}
|
||||
id={option.value}
|
||||
textValue={option.label}
|
||||
className={({ isFocused, isSelected }) =>
|
||||
stylex.props(
|
||||
styles.item,
|
||||
shared.insetFocusRing,
|
||||
isSelected && styles.itemSelected,
|
||||
isFocused && styles.itemFocused,
|
||||
).className ?? ""
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</ListBoxItem>
|
||||
))}
|
||||
</ListBox>
|
||||
</Popover>
|
||||
</AriaSelect>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user