@domicile-desktop/component-library 0.0.0-alpha-75b2a946029c → 0.0.0-alpha-d8c1903a165f

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -26,12 +26,14 @@
26
26
  "vite": "^8.2.2"
27
27
  },
28
28
  "exports": {
29
+ "./Accordion": "./src/Accordion/Accordion.tsx",
29
30
  "./Autocomplete": "./src/Autocomplete/Autocomplete.tsx",
30
31
  "./Avatar": "./src/Avatar/Avatar.tsx",
31
32
  "./Button": "./src/Button/Button.tsx",
32
33
  "./Card": "./src/Card/Card.tsx",
33
34
  "./control-sizes": "./src/control-sizes.ts",
34
35
  "./DisplayProvider": "./src/Screen/DisplayProvider.tsx",
36
+ "./Drilldown": "./src/Drilldown/Drilldown.tsx",
35
37
  "./display-source": "./src/Screen/display-source.ts",
36
38
  "./Field": "./src/Field/Field.tsx",
37
39
  "./Input": "./src/Input/Input.tsx",
@@ -86,5 +88,5 @@
86
88
  "test:unit": "bun test"
87
89
  },
88
90
  "type": "module",
89
- "version": "0.0.0-alpha-75b2a946029c"
91
+ "version": "0.0.0-alpha-d8c1903a165f"
90
92
  }
@@ -0,0 +1,135 @@
1
+ import { Accordion as BaseAccordion } from "@base-ui/react/accordion";
2
+ import { CaretDownIcon } from "@phosphor-icons/react/dist/ssr/CaretDown";
3
+ import type { ReactNode } from "react";
4
+
5
+ import { css } from "../../styled-system/css";
6
+ import { flex, hstack } from "../../styled-system/patterns";
7
+ import type { ExtendProps } from "../extend-props";
8
+
9
+ /**
10
+ * One section: the `value` that names it, the `label` on its button, and the
11
+ * `content` it opens to.
12
+ */
13
+ export type AccordionItem = {
14
+ value: string;
15
+ label: ReactNode;
16
+ content: ReactNode;
17
+ disabled?: boolean | undefined;
18
+ };
19
+
20
+ type Props = ExtendProps<
21
+ typeof BaseAccordion.Root<string>,
22
+ {
23
+ items: readonly AccordionItem[];
24
+ }
25
+ >;
26
+
27
+ /**
28
+ * Sections stacked under their labels, each opening in place with the rest
29
+ * pushed down: more of a panel without leaving it.
30
+ *
31
+ * Wraps the @base-ui/react Accordion primitive, which owns the behavior — the
32
+ * buttons' `aria-expanded`, arrow keys between them, the panels' heights — so
33
+ * this layer is the look: a label and a caret that turns, a panel that grows
34
+ * to its content. One section open at a time unless `multiple`; closed panels
35
+ * unmount, so what is inside holds no state while it is shut.
36
+ *
37
+ * **Drawn in `currentcolor`**, as `Slider` is, so it reads in a card and over
38
+ * a photograph alike.
39
+ */
40
+ export const Accordion = ({ items, ...rootProps }: Props) => (
41
+ <BaseAccordion.Root className={rootStyles} {...rootProps}>
42
+ {items.map((item) => (
43
+ <BaseAccordion.Item
44
+ className={itemStyles}
45
+ disabled={item.disabled ?? false}
46
+ key={item.value}
47
+ value={item.value}
48
+ >
49
+ <BaseAccordion.Header className={headerStyles}>
50
+ <BaseAccordion.Trigger className={triggerStyles}>
51
+ <span className={labelStyles}>{item.label}</span>
52
+ <CaretDownIcon className={caretStyles} size={12} />
53
+ </BaseAccordion.Trigger>
54
+ </BaseAccordion.Header>
55
+ <BaseAccordion.Panel className={panelStyles}>
56
+ <div className={contentStyles}>{item.content}</div>
57
+ </BaseAccordion.Panel>
58
+ </BaseAccordion.Item>
59
+ ))}
60
+ </BaseAccordion.Root>
61
+ );
62
+
63
+ const rootStyles = flex({
64
+ direction: "column",
65
+ inlineSize: "100%",
66
+ });
67
+
68
+ // A hairline between sections, in the color of whatever holds them.
69
+ const itemStyles = css({
70
+ "& + &": {
71
+ borderBlockStart:
72
+ "1px solid color-mix(in oklab, currentcolor 15%, transparent)",
73
+ },
74
+ });
75
+
76
+ const headerStyles = css({
77
+ margin: 0,
78
+ });
79
+
80
+ const triggerStyles = hstack({
81
+ _disabled: {
82
+ cursor: "not-allowed",
83
+ opacity: "disabled",
84
+ },
85
+ _focusVisible: {
86
+ backgroundColor: "color-mix(in oklab, currentcolor 12%, transparent)",
87
+ outlineColor: "transparent",
88
+ },
89
+ _hoverEnabled: {
90
+ backgroundColor: "color-mix(in oklab, currentcolor 8%, transparent)",
91
+ },
92
+ backgroundColor: "transparent",
93
+ borderRadius: "sm",
94
+ borderStyle: "none",
95
+ color: "inherit",
96
+ cursor: "pointer",
97
+ font: "inherit",
98
+ gap: 2,
99
+ inlineSize: "100%",
100
+ justify: "space-between",
101
+ paddingBlock: 1.5,
102
+ paddingInline: 1,
103
+ textAlign: "start",
104
+ transition: "background-color {durations.fast} {easings.out}",
105
+ });
106
+
107
+ const labelStyles = css({
108
+ flexGrow: 1,
109
+ fontWeight: "medium",
110
+ minInlineSize: 0,
111
+ });
112
+
113
+ // Turned over while its section is open: base-ui marks the trigger.
114
+ const caretStyles = css({
115
+ "[data-panel-open] > &": {
116
+ transform: "rotate(180deg)",
117
+ },
118
+ flexShrink: 0,
119
+ transition: "transform {durations.normal} {easings.out}",
120
+ });
121
+
122
+ // Grows to its content and back, off the height base-ui measures.
123
+ const panelStyles = css({
124
+ "&[data-ending-style], &[data-starting-style]": {
125
+ blockSize: 0,
126
+ },
127
+ blockSize: "var(--accordion-panel-height)",
128
+ overflow: "hidden",
129
+ transition: "block-size {durations.normal} {easings.out}",
130
+ });
131
+
132
+ const contentStyles = css({
133
+ paddingBlockEnd: 2,
134
+ paddingInline: 1,
135
+ });
@@ -0,0 +1,147 @@
1
+ import { CaretLeftIcon } from "@phosphor-icons/react/dist/ssr/CaretLeft";
2
+ import type { ReactNode } from "react";
3
+ import { useState } from "react";
4
+
5
+ import { css, cva } from "../../styled-system/css";
6
+ import { hstack } from "../../styled-system/patterns";
7
+
8
+ /** What slides in: a title over its content. */
9
+ export type DrilldownDetail = {
10
+ title: string;
11
+ content: ReactNode;
12
+ };
13
+
14
+ type Props = {
15
+ /** The main view, shown while there is no detail. */
16
+ children: ReactNode;
17
+ /** What slides in over the main view, or `undefined` for none. */
18
+ detail: DrilldownDetail | undefined;
19
+ /** The back button was pressed: drop the detail. */
20
+ onBack: () => void;
21
+ };
22
+
23
+ /**
24
+ * A view that something slides in over from the side and back out of: a list
25
+ * of choices for one row of the main view, with a back button above it — the
26
+ * way a phone's settings go one level deeper without leaving the screen.
27
+ *
28
+ * For a panel that has no room for a second panel, or no business opening
29
+ * one: a popover that a `Select`'s own popup would close, say.
30
+ *
31
+ * **The view that is not shown is not there**: `inert` and `aria-hidden`, so a
32
+ * keyboard and a screen reader skip it, and out of the flow, so the panel is as tall as what
33
+ * it shows. The detail that slid out is kept until the next one replaces it,
34
+ * so it can slide out rather than vanish.
35
+ *
36
+ * Drawn in `currentcolor`, as `Slider` is.
37
+ */
38
+ export const Drilldown = ({ children, detail, onBack }: Props) => {
39
+ const [shown, setShown] = useState(detail);
40
+ if (detail !== undefined && detail !== shown) {
41
+ setShown(detail);
42
+ }
43
+ const open = detail !== undefined;
44
+ return (
45
+ <div className={rootStyles}>
46
+ <div
47
+ aria-hidden={open}
48
+ className={paneStyles({ place: open ? "before" : "here" })}
49
+ inert={open}
50
+ >
51
+ {children}
52
+ </div>
53
+ <div
54
+ aria-hidden={!open}
55
+ className={paneStyles({ place: open ? "here" : "after" })}
56
+ inert={!open}
57
+ >
58
+ {shown !== undefined && (
59
+ <>
60
+ <div className={headerStyles}>
61
+ <button
62
+ aria-label="Back"
63
+ className={backStyles}
64
+ onClick={onBack}
65
+ type="button"
66
+ >
67
+ <CaretLeftIcon size={14} />
68
+ </button>
69
+ <h3 className={titleStyles}>{shown.title}</h3>
70
+ </div>
71
+ {shown.content}
72
+ </>
73
+ )}
74
+ </div>
75
+ </div>
76
+ );
77
+ };
78
+
79
+ const rootStyles = css({
80
+ overflow: "hidden",
81
+ position: "relative",
82
+ });
83
+
84
+ // The pane shown is in the flow; the other is laid over it, slid off to its
85
+ // side and hidden once it has gone — `visibility` waits out the slide.
86
+ const paneStyles = cva({
87
+ base: {
88
+ transition:
89
+ "transform {durations.normal} {easings.out}, visibility {durations.normal}",
90
+ },
91
+ variants: {
92
+ place: {
93
+ after: {
94
+ insetBlockStart: 0,
95
+ insetInline: 0,
96
+ position: "absolute",
97
+ transform: "translateX(100%)",
98
+ visibility: "hidden",
99
+ },
100
+ before: {
101
+ insetBlockStart: 0,
102
+ insetInline: 0,
103
+ position: "absolute",
104
+ transform: "translateX(-100%)",
105
+ visibility: "hidden",
106
+ },
107
+ here: {
108
+ position: "relative",
109
+ transform: "translateX(0)",
110
+ visibility: "visible",
111
+ },
112
+ },
113
+ },
114
+ });
115
+
116
+ const headerStyles = hstack({
117
+ gap: 1,
118
+ paddingBlockEnd: 1.5,
119
+ });
120
+
121
+ const backStyles = css({
122
+ _focusVisible: {
123
+ backgroundColor: "color-mix(in oklab, currentcolor 16%, transparent)",
124
+ outlineColor: "transparent",
125
+ },
126
+ _hover: {
127
+ backgroundColor: "color-mix(in oklab, currentcolor 12%, transparent)",
128
+ },
129
+ alignItems: "center",
130
+ backgroundColor: "transparent",
131
+ blockSize: 6,
132
+ borderRadius: "full",
133
+ borderStyle: "none",
134
+ color: "inherit",
135
+ cursor: "pointer",
136
+ display: "inline-flex",
137
+ flexShrink: 0,
138
+ inlineSize: 6,
139
+ justifyContent: "center",
140
+ padding: 0,
141
+ });
142
+
143
+ const titleStyles = css({
144
+ fontSize: "sm",
145
+ fontWeight: "semibold",
146
+ margin: 0,
147
+ });
@@ -147,12 +147,14 @@ const popupStyles = flex({
147
147
  },
148
148
  // A frosted pill lettered white over any photo: the bar's language rather
149
149
  // than a card's, and the same in both themes, since the wallpaper does not
150
- // flip with them.
150
+ // flip with them. A pill while it is one row — the radius is half a row's
151
+ // height, as the bar's own chips are — and a rounded panel once its content
152
+ // is taller, rather than a stadium.
151
153
  "&[data-tone=overPhoto]": {
152
154
  backdropFilter: "blur({spacing.3})",
153
155
  backgroundColor: "panelOverPhoto",
154
156
  border: "none",
155
- borderRadius: "full",
157
+ borderRadius: "2xl",
156
158
  color: "onPhoto",
157
159
  paddingBlock: 1.5,
158
160
  paddingInline: 3,
@@ -8,6 +8,11 @@ type Props = Omit<
8
8
  > & {
9
9
  /** What the slider sets, for a screen reader: the thumb's name. */
10
10
  label: string;
11
+ /**
12
+ * How loud, or how full, what the slider sets is right now, 0 through 1 —
13
+ * a meter drawn under the track, named for the slider. Absent for no meter.
14
+ */
15
+ level?: number | undefined;
11
16
  };
12
17
 
13
18
  /**
@@ -21,17 +26,38 @@ type Props = Omit<
21
26
  * asks for one. Every interaction — pointer, keyboard, the hidden range input a
22
27
  * screen reader drives — is base-ui's.
23
28
  */
24
- export const Slider = ({ label, ...rootProps }: Props) => (
29
+ export const Slider = ({ label, level, ...rootProps }: Props) => (
25
30
  <BaseSlider.Root className={rootStyles} {...rootProps}>
26
31
  <BaseSlider.Control className={controlStyles}>
27
32
  <BaseSlider.Track className={trackStyles}>
28
33
  <BaseSlider.Indicator className={indicatorStyles} />
29
34
  <BaseSlider.Thumb aria-label={label} className={thumbStyles} />
30
35
  </BaseSlider.Track>
36
+ {level !== undefined && <Meter label={label} level={level} />}
31
37
  </BaseSlider.Control>
32
38
  </BaseSlider.Root>
33
39
  );
34
40
 
41
+ /**
42
+ * The level, as a hairline under the track: thin enough not to read as a
43
+ * second slider, and in the slider's own color.
44
+ */
45
+ const Meter = ({ label, level }: { label: string; level: number }) => {
46
+ const percent = Math.round(Math.min(1, Math.max(0, level)) * 100);
47
+ return (
48
+ <span
49
+ aria-label={`${label} level`}
50
+ aria-valuemax={100}
51
+ aria-valuemin={0}
52
+ aria-valuenow={percent}
53
+ className={meterStyles}
54
+ role="meter"
55
+ >
56
+ <span className={meterFillStyles} style={{ inlineSize: `${percent}%` }} />
57
+ </span>
58
+ );
59
+ };
60
+
35
61
  // As wide as whatever holds it, and a flex item that takes what is left of a
36
62
  // row rather than shrinking to its own empty content.
37
63
  const rootStyles = css({
@@ -45,10 +71,32 @@ const controlStyles = css({
45
71
  blockSize: 5,
46
72
  cursor: "pointer",
47
73
  display: "flex",
74
+ position: "relative",
48
75
  touchAction: "none",
49
76
  userSelect: "none",
50
77
  });
51
78
 
79
+ // Pinned to the foot of the control, under the track and out of its way.
80
+ const meterStyles = css({
81
+ backgroundColor: "color-mix(in oklab, currentcolor 12%, transparent)",
82
+ blockSize: "2px",
83
+ borderRadius: "full",
84
+ insetBlockEnd: 0,
85
+ insetInline: 0,
86
+ overflow: "hidden",
87
+ pointerEvents: "none",
88
+ position: "absolute",
89
+ });
90
+
91
+ // Quick to rise and quick to fall: the levels arrive twenty times a second,
92
+ // and a slower ease would draw a level that had already gone.
93
+ const meterFillStyles = css({
94
+ backgroundColor: "color-mix(in oklab, currentcolor 70%, transparent)",
95
+ blockSize: "100%",
96
+ display: "block",
97
+ transition: "inline-size {durations.fastest} {easings.linear}",
98
+ });
99
+
52
100
  const trackStyles = css({
53
101
  backgroundColor: "color-mix(in oklab, currentcolor 25%, transparent)",
54
102
  blockSize: 1,