@threadlabs/looma 0.7.2 → 0.7.4

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threadlabs/looma",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "type": "module",
5
5
  "description": "Looma components as HTML, Vue, and plain DOM, with design tokens.",
6
6
  "license": "MIT",
@@ -95,6 +95,24 @@
95
95
  --_badge-text: var(--ui-info-subtle-text, var(--ui-info-solid));
96
96
  }
97
97
 
98
+ /* A tag is flat at the start and points at the end. The point is cut from the box, so it has no
99
+ outline to follow: the edge is the fill. The end padding grows by the point's depth so the
100
+ label never runs into it. */
101
+ :scope[data-ui-badge-state~="shape=tag"] {
102
+ --_badge-point: var(--ui-badge-point, var(--ui-space-2));
103
+
104
+ border-color: transparent;
105
+ border-radius: 0;
106
+ padding-inline-end: calc(var(--ui-badge-padding-inline, var(--ui-space-2)) + var(--_badge-point));
107
+ clip-path: polygon(0 0, calc(100% - var(--_badge-point)) 0, 100% 50%, calc(100% - var(--_badge-point)) 100%, 0 100%);
108
+ }
109
+
110
+ :scope[data-ui-badge-state~="shape=tag"]:dir(rtl) {
111
+ padding-inline-end: var(--ui-badge-padding-inline, var(--ui-space-2));
112
+ padding-inline-start: calc(var(--ui-badge-padding-inline, var(--ui-space-2)) + var(--_badge-point));
113
+ clip-path: polygon(var(--_badge-point) 0, 100% 0, 100% 100%, var(--_badge-point) 100%, 0 50%);
114
+ }
115
+
98
116
  /* A component that sets its display still honours the hidden attribute on its root. */
99
117
  :scope[hidden] {
100
118
  display: none;
@@ -107,5 +125,9 @@
107
125
  @supports (text-box-trim: trim-both) {
108
126
  }
109
127
 
128
+ /* A tag is flat at the start and points at the end. The point is cut from the box, so it has no
129
+ outline to follow: the edge is the fill. The end padding grows by the point's depth so the
130
+ label never runs into it. */
131
+
110
132
  /* A component that sets its display still honours the hidden attribute on its root. */
111
133
  }
@@ -41,6 +41,7 @@
41
41
  color: var(--ui-button-text, var(--_tone-text));
42
42
  font: inherit;
43
43
  font-weight: var(--ui-font-medium);
44
+ text-decoration: none;
44
45
  line-height: var(--ui-line-height-tight);
45
46
  padding: var(--ui-space-2) var(--ui-space-3);
46
47
  background-image: var(--ui-raised-highlight);
@@ -73,13 +74,15 @@
73
74
  font-size: 1rem;
74
75
  }
75
76
 
76
- :scope:hover:enabled {
77
+ /* A link is never :enabled, so interactive states test "not unavailable" instead: for a button that
78
+ is exactly :enabled, at the same specificity, and it also matches a link that can be followed. */
79
+ :scope:hover:not(:disabled, [aria-disabled="true"]) {
77
80
  border-color: var(--ui-button-hover-border, var(--_tone-hover));
78
81
  background-color: var(--ui-button-hover-surface, oklch(from var(--_tone) l c h / 12%));
79
82
  }
80
83
 
81
84
  /* Pressed reads as pressed in: the highlight goes, and the shadow moves inside. */
82
- :scope:active:enabled {
85
+ :scope:active:not(:disabled, [aria-disabled="true"]) {
83
86
  background-color: var(--ui-button-active-surface, oklch(from var(--_tone) l c h / 18%));
84
87
  background-image: none;
85
88
  box-shadow: var(--ui-pressed);
@@ -92,12 +95,12 @@
92
95
  box-shadow: var(--ui-button-solid-shadow, var(--ui-raised));
93
96
  }
94
97
 
95
- :is(:scope[data-ui-button-state~="variant=solid"], :scope[data-ui-button-state~="variant=danger"]):hover:enabled {
98
+ :is(:scope[data-ui-button-state~="variant=solid"], :scope[data-ui-button-state~="variant=danger"]):hover:not(:disabled, [aria-disabled="true"]) {
96
99
  background-color: var(--_tone-hover);
97
100
  border-color: var(--_tone-hover);
98
101
  }
99
102
 
100
- :is(:scope[data-ui-button-state~="variant=solid"], :scope[data-ui-button-state~="variant=danger"]):active:enabled {
103
+ :is(:scope[data-ui-button-state~="variant=solid"], :scope[data-ui-button-state~="variant=danger"]):active:not(:disabled, [aria-disabled="true"]) {
101
104
  background-color: var(--_tone-active);
102
105
  border-color: var(--_tone-active);
103
106
  background-image: none;
@@ -154,12 +157,12 @@
154
157
  }
155
158
 
156
159
  /* Tints derive from the text colour, not a surface token, so hover and press show on any background. */
157
- :scope[data-ui-button-state~="variant=ghost"]:hover:enabled {
160
+ :scope[data-ui-button-state~="variant=ghost"]:hover:not(:disabled, [aria-disabled="true"]) {
158
161
  background-color: var(--ui-button-ghost-hover-surface, oklch(from var(--_tone) l c h / 10%));
159
162
  border-color: transparent;
160
163
  }
161
164
 
162
- :scope[data-ui-button-state~="variant=ghost"]:active:enabled {
165
+ :scope[data-ui-button-state~="variant=ghost"]:active:not(:disabled, [aria-disabled="true"]) {
163
166
  background-color: var(--ui-button-ghost-active-surface, oklch(from var(--_tone) l c h / 16%));
164
167
  border-color: transparent;
165
168
  box-shadow: var(--ui-pressed);
@@ -180,10 +183,10 @@
180
183
  }
181
184
 
182
185
  /* Disabled drops the tone rather than the shape: every variant keeps its own structure, so a
183
- disabled outline is still an outline and a disabled ghost is still text. Intent goes — an
186
+ disabled outline is still an outline and a disabled ghost still has no edge. Intent goes — an
184
187
  unavailable action says "unavailable", not "unavailable, and destructive" — and so do the
185
188
  highlight and shadow, because nothing that cannot be pressed should look raised. */
186
- :scope:disabled {
189
+ :scope:is(:disabled, [aria-disabled="true"]) {
187
190
  /* Derived from the tone itself, not from a separate disabled palette: faded toward the
188
191
  page, which desaturates it at the same time. Toward the page rather than toward white,
189
192
  so a dark theme dims where a light one lightens — the same rule, both schemes. */
@@ -200,14 +203,16 @@
200
203
  }
201
204
 
202
205
  /* A filled button keeps its fill, faded further so its text stays legible on it. */
203
- :is(:scope[data-ui-button-state~="variant=solid"], :scope[data-ui-button-state~="variant=danger"]):disabled {
206
+ :is(:scope[data-ui-button-state~="variant=solid"], :scope[data-ui-button-state~="variant=danger"]):is(:disabled, [aria-disabled="true"]) {
204
207
  --_tone: color-mix(in oklab, var(--_hue) 34%, var(--ui-surface));
205
208
  }
206
209
 
207
210
  /* Ghost has no hover to fall back on, so disabled is the one state it has to state outright:
208
- it takes a surface, light and tinted, rather than reading as plain text. */
209
- :scope[data-ui-button-state~="variant=ghost"]:disabled {
210
- background-color: color-mix(in oklab, var(--_hue) 28%, var(--ui-surface));
211
+ it takes a surface rather than reading as plain text. The surface is a wash of the faded tone,
212
+ the way hover washes the live one, so it stays light in every tone. An opaque mix toward the
213
+ hue came out a mid-grey slab for neutral, whose hue is the ink: louder than the enabled button. */
214
+ :scope[data-ui-button-state~="variant=ghost"]:is(:disabled, [aria-disabled="true"]) {
215
+ background-color: oklch(from var(--_tone) l c h / 14%);
211
216
  border-color: transparent;
212
217
  }
213
218
 
@@ -236,19 +241,19 @@
236
241
  transition: color var(--ui-motion-fast) var(--ui-motion-ease), text-decoration-color var(--ui-motion-fast) var(--ui-motion-ease);
237
242
  }
238
243
 
239
- :scope[data-ui-button-state~="variant=link"]:hover:enabled {
244
+ :scope[data-ui-button-state~="variant=link"]:hover:not(:disabled, [aria-disabled="true"]) {
240
245
  color: var(--ui-button-link-hover-text, var(--ui-text-primary));
241
246
  text-decoration-color: currentColor;
242
247
  background: none;
243
248
  transform: none;
244
249
  }
245
250
 
246
- :scope[data-ui-button-state~="variant=link"]:active:enabled {
251
+ :scope[data-ui-button-state~="variant=link"]:active:not(:disabled, [aria-disabled="true"]) {
247
252
  background: none;
248
253
  transform: none;
249
254
  }
250
255
 
251
- :scope[data-ui-button-state~="variant=link"]:disabled {
256
+ :scope[data-ui-button-state~="variant=link"]:is(:disabled, [aria-disabled="true"]) {
252
257
  background: none;
253
258
  border-color: transparent;
254
259
  color: var(--ui-button-disabled-text, var(--ui-disabled-text));
@@ -270,6 +275,9 @@
270
275
  @supports (text-box-trim: trim-both) {
271
276
  }
272
277
 
278
+ /* A link is never :enabled, so interactive states test "not unavailable" instead: for a button that
279
+ is exactly :enabled, at the same specificity, and it also matches a link that can be followed. */
280
+
273
281
  /* Pressed reads as pressed in: the highlight goes, and the shadow moves inside. */
274
282
 
275
283
  /* Tone is the colour, variant is the volume. Every pairing works: a danger outline is a
@@ -280,14 +288,16 @@
280
288
  /* A soft halo that transitions in with the other box-shadow changes, layered on the raised shadow. */
281
289
 
282
290
  /* Disabled drops the tone rather than the shape: every variant keeps its own structure, so a
283
- disabled outline is still an outline and a disabled ghost is still text. Intent goes — an
291
+ disabled outline is still an outline and a disabled ghost still has no edge. Intent goes — an
284
292
  unavailable action says "unavailable", not "unavailable, and destructive" — and so do the
285
293
  highlight and shadow, because nothing that cannot be pressed should look raised. */
286
294
 
287
295
  /* A filled button keeps its fill, faded further so its text stays legible on it. */
288
296
 
289
297
  /* Ghost has no hover to fall back on, so disabled is the one state it has to state outright:
290
- it takes a surface, light and tinted, rather than reading as plain text. */
298
+ it takes a surface rather than reading as plain text. The surface is a wash of the faded tone,
299
+ the way hover washes the live one, so it stays light in every tone. An opaque mix toward the
300
+ hue came out a mid-grey slab for neutral, whose hue is the ink: louder than the enabled button. */
291
301
 
292
302
  @media (pointer: coarse) {
293
303
  }
@@ -6,6 +6,7 @@ export interface UiBadgeElement extends HTMLSpanElement {
6
6
  removeEventListener<K extends keyof UiBadgeEventMap>(type: K, listener: (this: UiBadgeElement, event: UiBadgeEventMap[K]) => unknown, options?: boolean | EventListenerOptions): void;
7
7
  }
8
8
  export interface UiBadgeProps {
9
+ shape?: "pill" | "tag" | null;
9
10
  tone?: "neutral" | "accent" | "info" | "success" | "warning" | "danger" | null;
10
11
  variant?: "solid" | "subtle" | null;
11
12
  attributes?: Readonly<Record<string, string | number | boolean | null | undefined>>;
@@ -5,8 +5,9 @@ import "../styles/ui-badge.css";
5
5
  export function createUiBadge(options = {}) {
6
6
  const { attributes = {}, children = [], slots = {}, as, ...componentProps } = options;
7
7
  const projected = [];
8
- const prop0 = componentProps["tone"] === undefined ? "neutral" : componentProps["tone"];
9
- const prop1 = componentProps["variant"] === undefined ? "subtle" : componentProps["variant"];
8
+ const prop0 = componentProps["shape"] === undefined ? "pill" : componentProps["shape"];
9
+ const prop1 = componentProps["tone"] === undefined ? "neutral" : componentProps["tone"];
10
+ const prop2 = componentProps["variant"] === undefined ? "subtle" : componentProps["variant"];
10
11
  const element = document.createElement("span");
11
12
  for (const [name, value] of Object.entries(attributes)) {
12
13
  if (value === null || value === undefined || value === false) continue;
@@ -22,6 +23,7 @@ export function createUiBadge(options = {}) {
22
23
  } else {
23
24
  }
24
25
  manageGeneratedProps(element, [
26
+ { name: "shape", attribute: "data-shape", value: componentProps["shape"], default: "pill", type: ["pill","tag"], required: false },
25
27
  { name: "tone", attribute: "data-tone", value: componentProps["tone"], default: "neutral", type: ["neutral","accent","info","success","warning","danger"], required: false },
26
28
  { name: "variant", attribute: "data-variant", value: componentProps["variant"], default: "subtle", type: ["solid","subtle"], required: false },
27
29
  ]);
@@ -8,12 +8,17 @@ export interface UiButtonElement extends HTMLButtonElement {
8
8
  export interface UiButtonProps {
9
9
  align?: "center" | "start" | null;
10
10
  disabled?: boolean | null;
11
+ href?: string | null;
12
+ rel?: string | null;
11
13
  size?: "sm" | "md" | "lg" | null;
12
14
  stretch?: boolean | null;
15
+ target?: string | null;
13
16
  tone?: "accent" | "neutral" | "danger" | "success" | "warning" | "info" | null;
17
+ type?: "button" | "submit" | "reset" | null;
14
18
  variant?: "outline" | "solid" | "danger" | "ghost" | "link" | null;
15
19
  attributes?: Readonly<Record<string, string | number | boolean | null | undefined>>;
16
20
  children?: readonly (string | Node)[];
17
21
  slots?: Readonly<Record<string, readonly (string | Node)[]>>;
22
+ as?: "button" | "a";
18
23
  }
19
24
  export declare function createUiButton(props?: UiButtonProps): UiButtonElement;
@@ -2,20 +2,22 @@
2
2
  import { manageComponentLifecycle } from "@nextwebwg/html-next/runtime";
3
3
  import "../styles/ui-button.css";
4
4
 
5
- const definition = {...{"contract":{"tag":"ui-button","props":{"align":{"type":{"enum":["center","start"]},"required":false,"target":{"attribute":"align"},"default":"center"},"disabled":{"type":"boolean","required":false,"target":{"attribute":"disabled"},"default":false},"size":{"type":{"enum":["sm","md","lg"]},"required":false,"target":{"attribute":"size"},"default":"md"},"stretch":{"type":"boolean","required":false,"target":{"attribute":"stretch"},"default":false},"tone":{"type":{"enum":["accent","neutral","danger","success","warning","info"]},"required":false,"target":{"attribute":"tone"},"default":"accent"},"variant":{"type":{"enum":["outline","solid","danger","ghost","link"]},"required":false,"target":{"attribute":"variant"},"default":"outline"}}},"template":{"kind":"element","name":"button","attributes":[{"kind":"literal","name":"type","value":"button"},{"kind":"attribute","name":"disabled","expression":"disabled","expressionPlan":{"source":"disabled","ast":{"kind":"id","name":"disabled"},"dependencies":["disabled"]}}],"children":[{"kind":"slot"}]},"declarations":[],"root":{"kind":"native","element":"button","choices":["button"]}},source:{file:import.meta.url},css:""};
5
+ const definition = {...{"contract":{"tag":"ui-button","props":{"align":{"type":{"enum":["center","start"]},"required":false,"target":{"attribute":"align"},"default":"center"},"disabled":{"type":"boolean","required":false,"target":{"attribute":"disabled"},"default":false},"href":{"type":"string","required":false,"target":{"attribute":"href"}},"rel":{"type":"string","required":false,"target":{"attribute":"rel"}},"size":{"type":{"enum":["sm","md","lg"]},"required":false,"target":{"attribute":"size"},"default":"md"},"stretch":{"type":"boolean","required":false,"target":{"attribute":"stretch"},"default":false},"target":{"type":"string","required":false,"target":{"attribute":"target"}},"tone":{"type":{"enum":["accent","neutral","danger","success","warning","info"]},"required":false,"target":{"attribute":"tone"},"default":"accent"},"type":{"type":{"enum":["button","submit","reset"]},"required":false,"target":{"attribute":"type"},"default":"button"},"variant":{"type":{"enum":["outline","solid","danger","ghost","link"]},"required":false,"target":{"attribute":"variant"},"default":"outline"}}},"template":{"kind":"element","name":"button","attributes":[{"kind":"attribute","name":"type","expression":"{ true: type, false: null }[format('%s', not href)]","expressionPlan":{"source":"{ true: type, false: null }[format('%s', not href)]","ast":{"kind":"index","object":{"kind":"object","pairs":[{"key":"true","value":{"kind":"id","name":"type"}},{"key":"false","value":{"kind":"literal","value":null}}]},"index":{"kind":"call","fn":"format","args":[{"kind":"literal","value":"%s"},{"kind":"unary","op":"not","operand":{"kind":"id","name":"href"}}]}},"dependencies":["href","type"]}},{"kind":"attribute","name":"disabled","expression":"{ true: true, false: null }[format('%s', disabled and not href)]","expressionPlan":{"source":"{ true: true, false: null }[format('%s', disabled and not href)]","ast":{"kind":"index","object":{"kind":"object","pairs":[{"key":"true","value":{"kind":"literal","value":true}},{"key":"false","value":{"kind":"literal","value":null}}]},"index":{"kind":"call","fn":"format","args":[{"kind":"literal","value":"%s"},{"kind":"binary","op":"and","left":{"kind":"id","name":"disabled"},"right":{"kind":"unary","op":"not","operand":{"kind":"id","name":"href"}}}]}},"dependencies":["disabled","href"]}},{"kind":"attribute","name":"href","expression":"{ true: null, false: href }[format('%s', disabled)]","expressionPlan":{"source":"{ true: null, false: href }[format('%s', disabled)]","ast":{"kind":"index","object":{"kind":"object","pairs":[{"key":"true","value":{"kind":"literal","value":null}},{"key":"false","value":{"kind":"id","name":"href"}}]},"index":{"kind":"call","fn":"format","args":[{"kind":"literal","value":"%s"},{"kind":"id","name":"disabled"}]}},"dependencies":["disabled","href"]}},{"kind":"attribute","name":"target","expression":"target","expressionPlan":{"source":"target","ast":{"kind":"id","name":"target"},"dependencies":["target"]}},{"kind":"attribute","name":"rel","expression":"rel","expressionPlan":{"source":"rel","ast":{"kind":"id","name":"rel"},"dependencies":["rel"]}},{"kind":"attribute","name":"aria-disabled","expression":"{ true: 'true', false: null }[format('%s', disabled and href)]","expressionPlan":{"source":"{ true: 'true', false: null }[format('%s', disabled and href)]","ast":{"kind":"index","object":{"kind":"object","pairs":[{"key":"true","value":{"kind":"literal","value":"true"}},{"key":"false","value":{"kind":"literal","value":null}}]},"index":{"kind":"call","fn":"format","args":[{"kind":"literal","value":"%s"},{"kind":"binary","op":"and","left":{"kind":"id","name":"disabled"},"right":{"kind":"id","name":"href"}}]}},"dependencies":["disabled","href"]}}],"children":[{"kind":"slot"}]},"declarations":[],"root":{"kind":"native","element":"button","choices":["button","a"]}},source:{file:import.meta.url},css:""};
6
6
 
7
7
  export function createUiButton(options = {}) {
8
8
  const { attributes = {}, children = [], slots = {}, as, ...componentProps } = options;
9
9
  const projected = [];
10
- const element = document.createElement("button");
10
+ const element = document.createElement(as ?? "button");
11
11
  for (const [name, value] of Object.entries(attributes)) {
12
12
  if (value === null || value === undefined || value === false) continue;
13
13
  element.setAttribute(name, value === true ? "" : String(value));
14
14
  }
15
- if (!element.hasAttribute("type")) element.setAttribute("type", "button");
16
- const value0 = componentProps["disabled"] === undefined ? false : componentProps["disabled"];
17
- if (value0 === true) element.setAttribute("disabled", "");
18
- else element.removeAttribute("disabled");
15
+ const value0 = componentProps["target"] === undefined ? undefined : componentProps["target"];
16
+ if (value0 === null || value0 === undefined) element.removeAttribute("target");
17
+ else element.setAttribute("target", String(value0));
18
+ const value1 = componentProps["rel"] === undefined ? undefined : componentProps["rel"];
19
+ if (value1 === null || value1 === undefined) element.removeAttribute("rel");
20
+ else element.setAttribute("rel", String(value1));
19
21
  element.setAttribute("data-component", "ui-button");
20
22
  if (children.length > 0) {
21
23
  for (const child of children) {
package/vue/UiBadge.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  type __VLS_Props = {
2
+ shape?: 'pill' | 'tag';
2
3
  tone?: 'neutral' | 'accent' | 'info' | 'success' | 'warning' | 'danger';
3
4
  variant?: 'solid' | 'subtle';
4
5
  };
@@ -7,6 +8,7 @@ type __VLS_Slots = {} & {
7
8
  default?: (props: typeof __VLS_1) => any;
8
9
  };
9
10
  declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {
11
+ shape: "pill" | "tag";
10
12
  tone: "neutral" | "accent" | "info" | "success" | "warning" | "danger";
11
13
  variant: "solid" | "subtle";
12
14
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
package/vue/UiBadge.vue CHANGED
@@ -6,10 +6,12 @@ defineOptions({ inheritAttrs: false })
6
6
 
7
7
  const props = withDefaults(
8
8
  defineProps<{
9
+ shape?: 'pill' | 'tag'
9
10
  tone?: 'neutral' | 'accent' | 'info' | 'success' | 'warning' | 'danger'
10
11
  variant?: 'solid' | 'subtle'
11
12
  }>(),
12
13
  {
14
+ shape: 'pill',
13
15
  tone: 'neutral',
14
16
  variant: 'subtle',
15
17
  },
@@ -20,6 +22,7 @@ const hostState = computed(() =>
20
22
  [
21
23
  props.tone && `tone tone=${props.tone}`,
22
24
  props.variant && `variant variant=${props.variant}`,
25
+ props.shape && `shape shape=${props.shape}`,
23
26
  ].filter(Boolean).join(' ')
24
27
  )
25
28
  </script>
@@ -129,6 +132,32 @@ const hostState = computed(() =>
129
132
  --_badge-text: var(--ui-info-subtle-text, var(--ui-info-solid));
130
133
  }
131
134
 
135
+ /* A tag is flat at the start and points at the end. The point is cut from the box, so it has no
136
+ outline to follow: the edge is the fill. The end padding grows by the point's depth so the
137
+ label never runs into it. */
138
+ [data-component~="ui-badge"][data-ui-badge-state~="shape=tag"] {
139
+ --_badge-point: var(--ui-badge-point, var(--ui-space-2));
140
+
141
+ border-color: transparent;
142
+ border-radius: 0;
143
+ padding-inline-end: calc(var(--ui-badge-padding-inline, var(--ui-space-2)) + var(--_badge-point));
144
+ clip-path: polygon(
145
+ 0 0,
146
+ calc(100% - var(--_badge-point)) 0,
147
+ 100% 50%,
148
+ calc(100% - var(--_badge-point)) 100%,
149
+ 0 100%
150
+ );
151
+ }
152
+
153
+ [data-component~="ui-badge"][data-ui-badge-state~="shape=tag"]:dir(rtl) {
154
+ padding-inline-end: var(--ui-badge-padding-inline, var(--ui-space-2));
155
+ padding-inline-start: calc(
156
+ var(--ui-badge-padding-inline, var(--ui-space-2)) + var(--_badge-point)
157
+ );
158
+ clip-path: polygon(var(--_badge-point) 0, 100% 0, 100% 100%, var(--_badge-point) 100%, 0 50%);
159
+ }
160
+
132
161
  /* A component that sets its display still honours the hidden attribute on its root. */
133
162
  [data-component~="ui-badge"][hidden] {
134
163
  display: none;
package/vue/UiButton.d.ts CHANGED
@@ -1,18 +1,25 @@
1
1
  type __VLS_Props = {
2
+ as?: 'button' | 'a';
2
3
  align?: 'center' | 'start';
3
4
  disabled?: boolean;
5
+ href?: string;
6
+ rel?: string;
4
7
  size?: 'sm' | 'md' | 'lg';
5
8
  stretch?: boolean;
9
+ target?: string;
6
10
  tone?: 'accent' | 'neutral' | 'danger' | 'success' | 'warning' | 'info';
11
+ type?: 'button' | 'submit' | 'reset';
7
12
  variant?: 'outline' | 'solid' | 'danger' | 'ghost' | 'link';
8
13
  };
9
- declare var __VLS_1: {};
14
+ declare var __VLS_8: {};
10
15
  type __VLS_Slots = {} & {
11
- default?: (props: typeof __VLS_1) => any;
16
+ default?: (props: typeof __VLS_8) => any;
12
17
  };
13
18
  declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {
19
+ type: "button" | "submit" | "reset";
14
20
  tone: "accent" | "neutral" | "danger" | "success" | "warning" | "info";
15
21
  variant: "outline" | "solid" | "danger" | "ghost" | "link";
22
+ as: "button" | "a";
16
23
  align: "center" | "start";
17
24
  disabled: boolean;
18
25
  size: "sm" | "md" | "lg";
package/vue/UiButton.vue CHANGED
@@ -6,19 +6,26 @@ defineOptions({ inheritAttrs: false })
6
6
 
7
7
  const props = withDefaults(
8
8
  defineProps<{
9
+ as?: 'button' | 'a'
9
10
  align?: 'center' | 'start'
10
11
  disabled?: boolean
12
+ href?: string
13
+ rel?: string
11
14
  size?: 'sm' | 'md' | 'lg'
12
15
  stretch?: boolean
16
+ target?: string
13
17
  tone?: 'accent' | 'neutral' | 'danger' | 'success' | 'warning' | 'info'
18
+ type?: 'button' | 'submit' | 'reset'
14
19
  variant?: 'outline' | 'solid' | 'danger' | 'ghost' | 'link'
15
20
  }>(),
16
21
  {
22
+ as: 'button',
17
23
  align: 'center',
18
24
  disabled: false,
19
25
  size: 'md',
20
26
  stretch: false,
21
27
  tone: 'accent',
28
+ type: 'button',
22
29
  variant: 'outline',
23
30
  },
24
31
  )
@@ -33,18 +40,38 @@ const hostState = computed(() =>
33
40
  props.tone && `tone tone=${props.tone}`,
34
41
  ].filter(Boolean).join(' ')
35
42
  )
43
+
44
+ function text(value: unknown): string {
45
+ if (Array.isArray(value)) return value.map(text).join(' ')
46
+ return value === null || value === undefined || typeof value === 'object' ? '' : String(value)
47
+ }
48
+
49
+ function attribute(value: unknown): any {
50
+ if (
51
+ value === null || value === undefined || value === false
52
+ || (typeof value === 'object' && !Array.isArray(value))
53
+ ) return undefined
54
+ return value === true ? '' : text(value)
55
+ }
36
56
  </script>
37
57
 
38
58
  <template>
39
- <button
59
+ <component
60
+ :is="as"
40
61
  data-component="ui-button"
41
- type="button"
42
62
  v-bind="$attrs"
43
- :disabled="disabled"
63
+ :type="attribute(({ true: type, false: null })[`${!href}`])"
64
+ :disabled="attribute(({ true: true, false: null })[`${disabled && !href}`])"
65
+ :href="attribute(({ true: null, false: href })[`${disabled}`])"
66
+ :target="target"
67
+ :rel="rel"
68
+ :aria-disabled="typeof (({ true: 'true', false: null })[`${Boolean(disabled && href)}`]) === 'boolean'
69
+ ? String(({ true: 'true', false: null })[`${Boolean(disabled && href)}`])
70
+ : attribute(({ true: 'true', false: null })[`${Boolean(disabled && href)}`])"
44
71
  :data-ui-button-state="hostState || undefined"
45
72
  >
46
73
  <slot />
47
- </button>
74
+ </component>
48
75
  </template>
49
76
 
50
77
  <style scoped>
@@ -89,6 +116,7 @@ const hostState = computed(() =>
89
116
  color: var(--ui-button-text, var(--_tone-text));
90
117
  font: inherit;
91
118
  font-weight: var(--ui-font-medium);
119
+ text-decoration: none;
92
120
  line-height: var(--ui-line-height-tight);
93
121
  padding: var(--ui-space-2) var(--ui-space-3);
94
122
  background-image: var(--ui-raised-highlight);
@@ -121,13 +149,15 @@ const hostState = computed(() =>
121
149
  font-size: 1rem;
122
150
  }
123
151
 
124
- [data-component~="ui-button"]:hover:enabled {
152
+ /* A link is never :enabled, so interactive states test "not unavailable" instead: for a button that
153
+ is exactly :enabled, at the same specificity, and it also matches a link that can be followed. */
154
+ [data-component~="ui-button"]:hover:not(:disabled, [aria-disabled="true"]) {
125
155
  border-color: var(--ui-button-hover-border, var(--_tone-hover));
126
156
  background-color: var(--ui-button-hover-surface, oklch(from var(--_tone) l c h / 12%));
127
157
  }
128
158
 
129
159
  /* Pressed reads as pressed in: the highlight goes, and the shadow moves inside. */
130
- [data-component~="ui-button"]:active:enabled {
160
+ [data-component~="ui-button"]:active:not(:disabled, [aria-disabled="true"]) {
131
161
  background-color: var(--ui-button-active-surface, oklch(from var(--_tone) l c h / 18%));
132
162
  background-image: none;
133
163
  box-shadow: var(--ui-pressed);
@@ -141,12 +171,12 @@ const hostState = computed(() =>
141
171
  box-shadow: var(--ui-button-solid-shadow, var(--ui-raised));
142
172
  }
143
173
 
144
- :is([data-component~="ui-button"][data-ui-button-state~="variant=solid"], [data-component~="ui-button"][data-ui-button-state~="variant=danger"]):hover:enabled {
174
+ :is([data-component~="ui-button"][data-ui-button-state~="variant=solid"], [data-component~="ui-button"][data-ui-button-state~="variant=danger"]):hover:not(:disabled, [aria-disabled="true"]) {
145
175
  background-color: var(--_tone-hover);
146
176
  border-color: var(--_tone-hover);
147
177
  }
148
178
 
149
- :is([data-component~="ui-button"][data-ui-button-state~="variant=solid"], [data-component~="ui-button"][data-ui-button-state~="variant=danger"]):active:enabled {
179
+ :is([data-component~="ui-button"][data-ui-button-state~="variant=solid"], [data-component~="ui-button"][data-ui-button-state~="variant=danger"]):active:not(:disabled, [aria-disabled="true"]) {
150
180
  background-color: var(--_tone-active);
151
181
  border-color: var(--_tone-active);
152
182
  background-image: none;
@@ -204,12 +234,12 @@ const hostState = computed(() =>
204
234
  }
205
235
 
206
236
  /* Tints derive from the text colour, not a surface token, so hover and press show on any background. */
207
- [data-component~="ui-button"][data-ui-button-state~="variant=ghost"]:hover:enabled {
237
+ [data-component~="ui-button"][data-ui-button-state~="variant=ghost"]:hover:not(:disabled, [aria-disabled="true"]) {
208
238
  background-color: var(--ui-button-ghost-hover-surface, oklch(from var(--_tone) l c h / 10%));
209
239
  border-color: transparent;
210
240
  }
211
241
 
212
- [data-component~="ui-button"][data-ui-button-state~="variant=ghost"]:active:enabled {
242
+ [data-component~="ui-button"][data-ui-button-state~="variant=ghost"]:active:not(:disabled, [aria-disabled="true"]) {
213
243
  background-color: var(--ui-button-ghost-active-surface, oklch(from var(--_tone) l c h / 16%));
214
244
  border-color: transparent;
215
245
  box-shadow: var(--ui-pressed);
@@ -230,10 +260,10 @@ const hostState = computed(() =>
230
260
  }
231
261
 
232
262
  /* Disabled drops the tone rather than the shape: every variant keeps its own structure, so a
233
- disabled outline is still an outline and a disabled ghost is still text. Intent goes — an
263
+ disabled outline is still an outline and a disabled ghost still has no edge. Intent goes — an
234
264
  unavailable action says "unavailable", not "unavailable, and destructive" — and so do the
235
265
  highlight and shadow, because nothing that cannot be pressed should look raised. */
236
- [data-component~="ui-button"]:disabled {
266
+ [data-component~="ui-button"]:is(:disabled, [aria-disabled="true"]) {
237
267
  /* Derived from the tone itself, not from a separate disabled palette: faded toward the
238
268
  page, which desaturates it at the same time. Toward the page rather than toward white,
239
269
  so a dark theme dims where a light one lightens — the same rule, both schemes. */
@@ -250,14 +280,16 @@ const hostState = computed(() =>
250
280
  }
251
281
 
252
282
  /* A filled button keeps its fill, faded further so its text stays legible on it. */
253
- :is([data-component~="ui-button"][data-ui-button-state~="variant=solid"], [data-component~="ui-button"][data-ui-button-state~="variant=danger"]):disabled {
283
+ :is([data-component~="ui-button"][data-ui-button-state~="variant=solid"], [data-component~="ui-button"][data-ui-button-state~="variant=danger"]):is(:disabled, [aria-disabled="true"]) {
254
284
  --_tone: color-mix(in oklab, var(--_hue) 34%, var(--ui-surface));
255
285
  }
256
286
 
257
287
  /* Ghost has no hover to fall back on, so disabled is the one state it has to state outright:
258
- it takes a surface, light and tinted, rather than reading as plain text. */
259
- [data-component~="ui-button"][data-ui-button-state~="variant=ghost"]:disabled {
260
- background-color: color-mix(in oklab, var(--_hue) 28%, var(--ui-surface));
288
+ it takes a surface rather than reading as plain text. The surface is a wash of the faded tone,
289
+ the way hover washes the live one, so it stays light in every tone. An opaque mix toward the
290
+ hue came out a mid-grey slab for neutral, whose hue is the ink: louder than the enabled button. */
291
+ [data-component~="ui-button"][data-ui-button-state~="variant=ghost"]:is(:disabled, [aria-disabled="true"]) {
292
+ background-color: oklch(from var(--_tone) l c h / 14%);
261
293
  border-color: transparent;
262
294
  }
263
295
 
@@ -288,19 +320,19 @@ const hostState = computed(() =>
288
320
  text-decoration-color var(--ui-motion-fast) var(--ui-motion-ease);
289
321
  }
290
322
 
291
- [data-component~="ui-button"][data-ui-button-state~="variant=link"]:hover:enabled {
323
+ [data-component~="ui-button"][data-ui-button-state~="variant=link"]:hover:not(:disabled, [aria-disabled="true"]) {
292
324
  color: var(--ui-button-link-hover-text, var(--ui-text-primary));
293
325
  text-decoration-color: currentColor;
294
326
  background: none;
295
327
  transform: none;
296
328
  }
297
329
 
298
- [data-component~="ui-button"][data-ui-button-state~="variant=link"]:active:enabled {
330
+ [data-component~="ui-button"][data-ui-button-state~="variant=link"]:active:not(:disabled, [aria-disabled="true"]) {
299
331
  background: none;
300
332
  transform: none;
301
333
  }
302
334
 
303
- [data-component~="ui-button"][data-ui-button-state~="variant=link"]:disabled {
335
+ [data-component~="ui-button"][data-ui-button-state~="variant=link"]:is(:disabled, [aria-disabled="true"]) {
304
336
  background: none;
305
337
  border-color: transparent;
306
338
  color: var(--ui-button-disabled-text, var(--ui-disabled-text));
@@ -8,17 +8,22 @@ var UiBadge_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE
8
8
  inheritAttrs: false,
9
9
  __name: "UiBadge",
10
10
  props: {
11
+ shape: { default: "pill" },
11
12
  tone: { default: "neutral" },
12
13
  variant: { default: "subtle" }
13
14
  },
14
15
  setup(__props) {
15
16
  const props = __props;
16
17
  /** The values the styles' :host-state() rules test. */
17
- const hostState = computed(() => [props.tone && `tone tone=${props.tone}`, props.variant && `variant variant=${props.variant}`].filter(Boolean).join(" "));
18
+ const hostState = computed(() => [
19
+ props.tone && `tone tone=${props.tone}`,
20
+ props.variant && `variant variant=${props.variant}`,
21
+ props.shape && `shape shape=${props.shape}`
22
+ ].filter(Boolean).join(" "));
18
23
  return (_ctx, _cache) => {
19
24
  return openBlock(), createElementBlock("span", mergeProps({ "data-component": "ui-badge" }, _ctx.$attrs, { "data-ui-badge-state": hostState.value || void 0 }), [renderSlot(_ctx.$slots, "default", {}, void 0, true)], 16, _hoisted_1);
20
25
  };
21
26
  }
22
- }), [["__scopeId", "data-v-90c7d1bb"]]);
27
+ }), [["__scopeId", "data-v-c0ffc4d8"]]);
23
28
  //#endregion
24
29
  export { UiBadge_default as t };