@lostgradient/cinder 0.12.1 → 0.13.0

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.
Files changed (59) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/components.json +7 -5
  3. package/dist/components/diff-viewer/index.js +201 -101
  4. package/dist/components/json-schema-editor/index.js +229 -129
  5. package/dist/components/markdown-editor/index.js +207 -107
  6. package/dist/components/review-editor/index.js +291 -191
  7. package/dist/components/schedule-builder/index.js +205 -105
  8. package/dist/components/segment/index.js +119 -56
  9. package/dist/components/segment/segment.schema.d.ts +2 -2
  10. package/dist/components/segment/segment.schema.js +71 -33
  11. package/dist/components/segment/segment.svelte.d.ts +2 -1
  12. package/dist/components/segment/segment.types.d.ts +67 -10
  13. package/dist/components/segmented-control/index.js +193 -93
  14. package/dist/components/segmented-control/segmented-control-state.svelte.d.ts +4 -4
  15. package/dist/components/segmented-control/segmented-control.css +26 -6
  16. package/dist/components/segmented-control/segmented-control.schema.js +3 -3
  17. package/dist/components/segmented-control/segmented-control.svelte.d.ts +2 -1
  18. package/dist/components/segmented-control/segmented-control.types.d.ts +4 -4
  19. package/dist/components/sidebar/index.d.ts +1 -0
  20. package/dist/components/sidebar/index.js +9 -3
  21. package/dist/components/sidebar/sidebar.constants.d.ts +12 -0
  22. package/dist/index.d.ts +1 -1
  23. package/dist/index.js +466 -358
  24. package/dist/server/components/diff-viewer/index.js +4 -4
  25. package/dist/server/components/json-schema-editor/index.js +4 -4
  26. package/dist/server/components/markdown-editor/index.js +3 -3
  27. package/dist/server/components/review-editor/index.js +4 -4
  28. package/dist/server/components/schedule-builder/index.js +3 -3
  29. package/dist/server/components/segment/index.js +2 -2
  30. package/dist/server/components/segmented-control/index.js +3 -3
  31. package/dist/server/components/sidebar/index.js +7 -3
  32. package/dist/server/index.js +16 -10
  33. package/dist/server/{index.server-jgb8yh5k.js → index.server-2m1kenak.js} +1 -1
  34. package/dist/server/{index.server-jfrssta6.js → index.server-ck3fwrab.js} +1 -1
  35. package/dist/server/{index.server-sedjxt36.js → index.server-e89qfamd.js} +79 -35
  36. package/dist/server/{index.server-pag862vz.js → index.server-grgv455a.js} +2 -2
  37. package/dist/server/{index.server-2qj6s2mh.js → index.server-j570vb03.js} +7 -3
  38. package/dist/server/{index.server-xeqtpv61.js → index.server-mr68brd6.js} +49 -24
  39. package/dist/server/{index.server-qbkxz7r2.js → index.server-nx9c2ep1.js} +2 -2
  40. package/dist/server/{index.server-jj76374p.js → index.server-p071e531.js} +1 -1
  41. package/dist/server/{index.server-5gzyqv78.js → index.server-v3arexq8.js} +2 -2
  42. package/dist/server/{index.server-85p4ydph.js → index.server-zzd5wpbw.js} +1 -1
  43. package/package.json +1 -1
  44. package/src/components/segment/segment.schema.json +34 -7
  45. package/src/components/segment/segment.schema.ts +5 -47
  46. package/src/components/segment/segment.svelte +58 -23
  47. package/src/components/segment/segment.types.ts +81 -11
  48. package/src/components/segmented-control/segmented-control.css +26 -6
  49. package/src/components/segmented-control/segmented-control.examples.json +6 -0
  50. package/src/components/segmented-control/segmented-control.schema.json +2 -2
  51. package/src/components/segmented-control/segmented-control.schema.ts +2 -2
  52. package/src/components/segmented-control/segmented-control.svelte +50 -23
  53. package/src/components/segmented-control/segmented-control.types.ts +4 -7
  54. package/src/components/sidebar/index.ts +1 -0
  55. package/src/components/sidebar/sidebar.constants.ts +13 -0
  56. package/src/components/sidebar/sidebar.examples.json +6 -0
  57. package/src/components/sidebar/sidebar.svelte +4 -5
  58. package/src/index.ts +5 -1
  59. package/src/styles/foundation.css +2 -1
@@ -10,6 +10,22 @@
10
10
  "type": "boolean",
11
11
  "description": "Disable just this segment (independent of the control-level `disabled`)."
12
12
  },
13
+ "href": {
14
+ "type": "string",
15
+ "description": "Render this segment as a real link inside `SegmentedControl variant=\"navigation\"`."
16
+ },
17
+ "current": {
18
+ "type": "boolean",
19
+ "description": "Marks this linked segment as the current route/filter."
20
+ },
21
+ "currentToken": {
22
+ "enum": ["page", "step", "location", "date", "time", "true"],
23
+ "description": "`aria-current` token emitted while `current` is true. Defaults to `\"page\"`."
24
+ },
25
+ "value": {
26
+ "type": "string",
27
+ "description": "Value this segment represents. Required when `href` is not provided."
28
+ },
13
29
  "controls": {
14
30
  "type": "string",
15
31
  "description": "ID of the panel this segment controls — only meaningful when the parent\n`SegmentedControl` uses `variant=\"tablist\"`."
@@ -30,16 +46,27 @@
30
46
  "description": "Optional decorative content rendered before the label, inside `aria-hidden`."
31
47
  },
32
48
  {
33
- "name": "trailing",
49
+ "name": "onclick",
34
50
  "reason": "function-or-snippet",
35
- "description": "Optional decorative content rendered after the label, inside `aria-hidden`."
51
+ "description": "Optional click handler for the rendered link. Disabled navigation segments\nprevent default and do not call this handler."
36
52
  },
37
53
  {
38
- "name": "value",
39
- "reason": "unknown-shape",
40
- "required": true,
41
- "description": "Value this segment represents. Must be unique within the parent control."
54
+ "name": "trailing",
55
+ "reason": "function-or-snippet",
56
+ "description": "Optional decorative content rendered after the label, inside `aria-hidden`."
42
57
  }
43
58
  ]
44
- }
59
+ },
60
+ "allOf": [
61
+ {
62
+ "if": {
63
+ "not": {
64
+ "required": ["href"]
65
+ }
66
+ },
67
+ "then": {
68
+ "required": ["value"]
69
+ }
70
+ }
71
+ ]
45
72
  }
@@ -1,50 +1,8 @@
1
1
  import type { ComponentSchema } from '../../schema-types';
2
2
 
3
- const schema = {
4
- $schema: 'https://json-schema.org/draft/2020-12/schema',
5
- type: 'object',
6
- properties: {
7
- class: {
8
- type: 'string',
9
- description: 'Custom class merged with `.cinder-segmented-control-option`.',
10
- },
11
- disabled: {
12
- type: 'boolean',
13
- description: 'Disable just this segment (independent of the control-level `disabled`).',
14
- },
15
- controls: {
16
- type: 'string',
17
- description:
18
- 'ID of the panel this segment controls — only meaningful when the parent\n`SegmentedControl` uses `variant="tablist"`.',
19
- },
20
- },
21
- additionalProperties: false,
22
- metadata: {
23
- unsupportedProps: [
24
- {
25
- name: 'children',
26
- reason: 'function-or-snippet',
27
- required: true,
28
- description: "The segment's label content.",
29
- },
30
- {
31
- name: 'leading',
32
- reason: 'function-or-snippet',
33
- description: 'Optional decorative content rendered before the label, inside `aria-hidden`.',
34
- },
35
- {
36
- name: 'trailing',
37
- reason: 'function-or-snippet',
38
- description: 'Optional decorative content rendered after the label, inside `aria-hidden`.',
39
- },
40
- {
41
- name: 'value',
42
- reason: 'unknown-shape',
43
- required: true,
44
- description: 'Value this segment represents. Must be unique within the parent control.',
45
- },
46
- ],
47
- },
48
- } satisfies ComponentSchema;
3
+ // eslint-disable-next-line no-unsafe-type-assertion -- generated schema parsed from a build-validated JSON string; see generate-component-schema.ts.
4
+ const schema = JSON.parse(
5
+ '{\n "$schema": "https://json-schema.org/draft/2020-12/schema",\n "type": "object",\n "properties": {\n "class": {\n "type": "string",\n "description": "Custom class merged with `.cinder-segmented-control-option`."\n },\n "disabled": {\n "type": "boolean",\n "description": "Disable just this segment (independent of the control-level `disabled`)."\n },\n "href": {\n "type": "string",\n "description": "Render this segment as a real link inside `SegmentedControl variant=\\"navigation\\"`."\n },\n "current": {\n "type": "boolean",\n "description": "Marks this linked segment as the current route/filter."\n },\n "currentToken": {\n "enum": [\n "page",\n "step",\n "location",\n "date",\n "time",\n "true"\n ],\n "description": "`aria-current` token emitted while `current` is true. Defaults to `\\"page\\"`."\n },\n "value": {\n "type": "string",\n "description": "Value this segment represents. Required when `href` is not provided."\n },\n "controls": {\n "type": "string",\n "description": "ID of the panel this segment controls — only meaningful when the parent\\n`SegmentedControl` uses `variant=\\"tablist\\"`."\n }\n },\n "additionalProperties": false,\n "metadata": {\n "unsupportedProps": [\n {\n "name": "children",\n "reason": "function-or-snippet",\n "required": true,\n "description": "The segment\'s label content."\n },\n {\n "name": "leading",\n "reason": "function-or-snippet",\n "description": "Optional decorative content rendered before the label, inside `aria-hidden`."\n },\n {\n "name": "onclick",\n "reason": "function-or-snippet",\n "description": "Optional click handler for the rendered link. Disabled navigation segments\\nprevent default and do not call this handler."\n },\n {\n "name": "trailing",\n "reason": "function-or-snippet",\n "description": "Optional decorative content rendered after the label, inside `aria-hidden`."\n }\n ]\n },\n "allOf": [\n {\n "if": {\n "not": {\n "required": [\n "href"\n ]\n }\n },\n "then": {\n "required": [\n "value"\n ]\n }\n }\n ]\n}',
6
+ ) as ComponentSchema;
49
7
 
50
- export default schema as ComponentSchema;
8
+ export default schema;
@@ -6,8 +6,9 @@ export {};
6
6
  import { untrack } from 'svelte';
7
7
  import { classNames } from '../../utilities/class-names.ts';
8
8
  import { getSegmentedControlContext } from '../segmented-control/segmented-control-state.svelte.ts';
9
- let { value, disabled = false, controls, leading, trailing, children, class: customClassName, ...rest } = $props();
9
+ let { value, href, download, target, rel, current = false, currentToken = 'page', disabled = false, controls, leading, trailing, children, class: customClassName, onclick, ...rest } = $props();
10
10
  const context = getSegmentedControlContext();
11
+ const buttonValue = $derived(value ?? href ?? '');
11
12
  const registerSegment = (node) => {
12
13
  // Attachments run inside a tracked effect. Wrap the call to register() in
13
14
  // `untrack` so the controller's mutation of its internal state does not
@@ -18,7 +19,7 @@ const registerSegment = (node) => {
18
19
  return untrack(() => context.register({
19
20
  node,
20
21
  get value() {
21
- return value;
22
+ return buttonValue;
22
23
  },
23
24
  get disabled() {
24
25
  return disabled || context.controlDisabled;
@@ -28,34 +29,28 @@ const registerSegment = (node) => {
28
29
  },
29
30
  }));
30
31
  };
31
- const isSelected = $derived(context.isSelected(value));
32
- const isFocusable = $derived(context.isFocusable(value));
32
+ const isSelected = $derived(context.isSelected(buttonValue));
33
+ const isFocusable = $derived(context.isFocusable(buttonValue));
33
34
  const effectiveDisabled = $derived(disabled || context.controlDisabled);
35
+ const isNavigationItem = $derived(context.variant === 'navigation');
36
+ const anchorDisabled = $derived(effectiveDisabled || href === undefined);
37
+ const elementAttributes = $derived(rest);
38
+ const anchorTabindex = $derived(anchorDisabled ? -1 : elementAttributes.tabindex);
34
39
  const role = $derived(context.selectionMode === 'multiple'
35
40
  ? undefined
36
41
  : context.variant === 'tablist'
37
42
  ? 'tab'
38
43
  : 'radio');
44
+ function handleAnchorClick(event) {
45
+ if (anchorDisabled) {
46
+ event.preventDefault();
47
+ return;
48
+ }
49
+ onclick?.(event);
50
+ }
39
51
  </script>
40
52
 
41
- <button
42
- {...rest}
43
- type="button"
44
- {role}
45
- data-cinder-segment-value={value}
46
- aria-checked={role === 'radio' ? isSelected : undefined}
47
- aria-selected={role === 'tab' ? isSelected : undefined}
48
- aria-pressed={context.selectionMode === 'multiple' ? isSelected : undefined}
49
- aria-controls={role === 'tab' ? controls : undefined}
50
- aria-disabled={effectiveDisabled ? 'true' : undefined}
51
- disabled={context.selectionMode === 'multiple' ? effectiveDisabled : undefined}
52
- tabindex={isFocusable ? 0 : -1}
53
- class={classNames('cinder-segmented-control-option', customClassName)}
54
- data-cinder-selected={isSelected ? '' : undefined}
55
- data-cinder-pressed={context.selectionMode === 'multiple' && isSelected ? '' : undefined}
56
- onclick={() => context.toggle(value)}
57
- {@attach registerSegment}
58
- >
53
+ {#snippet content()}
59
54
  {#if leading}
60
55
  <span class="cinder-segmented-control-option-icon" aria-hidden="true">
61
56
  {@render leading()}
@@ -70,4 +65,44 @@ const role = $derived(context.selectionMode === 'multiple'
70
65
  {@render trailing()}
71
66
  </span>
72
67
  {/if}
73
- </button>
68
+ {/snippet}
69
+
70
+ {#if isNavigationItem}
71
+ <a
72
+ {...elementAttributes}
73
+ href={anchorDisabled ? undefined : href}
74
+ {download}
75
+ {target}
76
+ {rel}
77
+ data-cinder-segment-value={buttonValue}
78
+ aria-current={current ? currentToken : undefined}
79
+ aria-disabled={anchorDisabled ? 'true' : undefined}
80
+ tabindex={anchorTabindex}
81
+ class={classNames('cinder-segmented-control-option', customClassName)}
82
+ data-cinder-current={current ? '' : undefined}
83
+ onclick={handleAnchorClick}
84
+ >
85
+ {@render content()}
86
+ </a>
87
+ {:else}
88
+ <button
89
+ {...elementAttributes}
90
+ type="button"
91
+ {role}
92
+ data-cinder-segment-value={buttonValue}
93
+ aria-checked={role === 'radio' ? isSelected : undefined}
94
+ aria-selected={role === 'tab' ? isSelected : undefined}
95
+ aria-pressed={context.selectionMode === 'multiple' ? isSelected : undefined}
96
+ aria-controls={role === 'tab' ? controls : undefined}
97
+ aria-disabled={effectiveDisabled ? 'true' : undefined}
98
+ disabled={context.selectionMode === 'multiple' ? effectiveDisabled : undefined}
99
+ tabindex={isFocusable ? 0 : -1}
100
+ class={classNames('cinder-segmented-control-option', customClassName)}
101
+ data-cinder-selected={isSelected ? '' : undefined}
102
+ data-cinder-pressed={context.selectionMode === 'multiple' && isSelected ? '' : undefined}
103
+ onclick={() => context.toggle(buttonValue)}
104
+ {@attach registerSegment}
105
+ >
106
+ {@render content()}
107
+ </button>
108
+ {/if}
@@ -1,39 +1,109 @@
1
1
  import type { Snippet } from 'svelte';
2
- import type { HTMLButtonAttributes } from 'svelte/elements';
2
+ import type { HTMLAnchorAttributes, HTMLAttributes } from 'svelte/elements';
3
+
4
+ export type SegmentCurrentToken = 'page' | 'step' | 'location' | 'date' | 'time' | 'true';
3
5
 
4
6
  // Attributes the component owns and computes itself, so a consumer value would
5
7
  // be silently overridden. `onfocus` / `onblur` are intentionally NOT here: the
6
8
  // component implements no focus handling, so forwarding them through `...rest`
7
9
  // lets consumers wire focus-driven behavior (tooltips, analytics) on a segment.
10
+ type SharedProps = {
11
+ /** Custom class merged with `.cinder-segmented-control-option`. */
12
+ class?: string | undefined;
13
+ /** Disable just this segment (independent of the control-level `disabled`). */
14
+ disabled?: boolean | undefined;
15
+ /** Optional decorative content rendered before the label, inside `aria-hidden`. */
16
+ leading?: Snippet | undefined;
17
+ /** Optional decorative content rendered after the label, inside `aria-hidden`. */
18
+ trailing?: Snippet | undefined;
19
+ /** The segment's label content. */
20
+ children: Snippet;
21
+ };
22
+
8
23
  type SegmentOwnedAttributes =
9
24
  | 'role'
10
25
  | 'type'
11
26
  | 'disabled'
12
27
  | 'tabindex'
13
28
  | 'class'
29
+ | 'href'
30
+ | 'download'
31
+ | 'target'
32
+ | 'rel'
14
33
  | 'aria-checked'
15
34
  | 'aria-selected'
16
35
  | 'aria-pressed'
17
36
  | 'aria-controls'
18
37
  | 'aria-disabled'
38
+ | 'aria-current'
19
39
  | 'onclick';
20
40
 
21
- export type SegmentProps = Omit<HTMLButtonAttributes, SegmentOwnedAttributes> & {
22
- /** Value this segment represents. Must be unique within the parent control. */
23
- value: string;
41
+ type SharedElementAttributes = Omit<HTMLAttributes<HTMLElement>, SegmentOwnedAttributes>;
42
+
43
+ type SegmentButtonProps = SharedElementAttributes &
44
+ SharedProps & {
45
+ href?: undefined;
46
+ download?: undefined;
47
+ target?: undefined;
48
+ rel?: undefined;
49
+ current?: undefined;
50
+ currentToken?: undefined;
51
+ onclick?: undefined;
52
+ /** Value this segment represents. Must be unique within the parent control. */
53
+ value: string;
54
+ /**
55
+ * ID of the panel this segment controls — only meaningful when the parent
56
+ * `SegmentedControl` uses `variant="tablist"`.
57
+ */
58
+ controls?: string | undefined;
59
+ };
60
+
61
+ type SegmentAnchorProps = SharedElementAttributes &
62
+ SharedProps & {
63
+ /** Render this segment as a real link inside `SegmentedControl variant="navigation"`. */
64
+ href: string;
65
+ /** Download hint for the rendered link. */
66
+ download?: boolean | string | undefined;
67
+ /** Browsing context for the rendered link. */
68
+ target?: HTMLAnchorAttributes['target'] | undefined;
69
+ /** Relationship metadata for the rendered link. */
70
+ rel?: HTMLAnchorAttributes['rel'] | undefined;
71
+ /** Optional tab index for enabled links. Disabled navigation links force `-1`. */
72
+ tabindex?: HTMLAttributes<HTMLElement>['tabindex'] | undefined;
73
+ /**
74
+ * Optional click handler for the rendered link. Disabled navigation segments
75
+ * prevent default and do not call this handler.
76
+ */
77
+ onclick?: ((event: MouseEvent) => void) | undefined;
78
+ /** Marks this linked segment as the current route/filter. */
79
+ current?: boolean | undefined;
80
+ /** `aria-current` token emitted while `current` is true. Defaults to `"page"`. */
81
+ currentToken?: SegmentCurrentToken | undefined;
82
+ /** Optional value for consumer metadata; navigation segments do not bind selection state. */
83
+ value?: string | undefined;
84
+ /** Panel controls apply only to tab segments, not navigation links. */
85
+ controls?: undefined;
86
+ };
87
+
88
+ export type SegmentProps = SegmentButtonProps | SegmentAnchorProps;
89
+
90
+ /** Schema generator surface for Segment's cinder-specific, JSON-expressible props. */
91
+ export interface SegmentSchemaProps {
24
92
  /** Custom class merged with `.cinder-segmented-control-option`. */
25
93
  class?: string | undefined;
26
94
  /** Disable just this segment (independent of the control-level `disabled`). */
27
95
  disabled?: boolean | undefined;
96
+ /** Render this segment as a real link inside `SegmentedControl variant="navigation"`. */
97
+ href?: string | undefined;
98
+ /** Marks this linked segment as the current route/filter. */
99
+ current?: boolean | undefined;
100
+ /** `aria-current` token emitted while `current` is true. Defaults to `"page"`. */
101
+ currentToken?: SegmentCurrentToken | undefined;
102
+ /** Value this segment represents. Required when `href` is not provided. */
103
+ value?: string | undefined;
28
104
  /**
29
105
  * ID of the panel this segment controls — only meaningful when the parent
30
106
  * `SegmentedControl` uses `variant="tablist"`.
31
107
  */
32
108
  controls?: string | undefined;
33
- /** Optional decorative content rendered before the label, inside `aria-hidden`. */
34
- leading?: Snippet | undefined;
35
- /** Optional decorative content rendered after the label, inside `aria-hidden`. */
36
- trailing?: Snippet | undefined;
37
- /** The segment's label content. */
38
- children: Snippet;
39
- };
109
+ }
@@ -98,6 +98,7 @@
98
98
  cursor: pointer;
99
99
  font-weight: var(--cinder-font-medium);
100
100
  line-height: 1;
101
+ text-decoration: none;
101
102
  transition:
102
103
  background-color var(--cinder-duration-fast) var(--cinder-ease-standard),
103
104
  color var(--cinder-duration-fast) var(--cinder-ease-standard),
@@ -117,11 +118,16 @@
117
118
 
118
119
  .cinder-segmented-control:not([data-cinder-detached])
119
120
  .cinder-segmented-control-option[data-cinder-selected]:not(:first-child)::before,
121
+ .cinder-segmented-control:not([data-cinder-detached])
122
+ .cinder-segmented-control-option[data-cinder-current]:not(:first-child)::before,
120
123
  .cinder-segmented-control:not([data-cinder-detached])
121
124
  .cinder-segmented-control-option[data-cinder-pressed]:not(:first-child)::before,
122
125
  .cinder-segmented-control:not([data-cinder-detached])
123
126
  [data-cinder-selected]
124
127
  + .cinder-segmented-control-option::before,
128
+ .cinder-segmented-control:not([data-cinder-detached])
129
+ [data-cinder-current]
130
+ + .cinder-segmented-control-option::before,
125
131
  .cinder-segmented-control:not([data-cinder-detached])
126
132
  [data-cinder-pressed]
127
133
  + .cinder-segmented-control-option::before {
@@ -211,9 +217,9 @@
211
217
  /* Sticky-hover suppression on touch: wrap hover-only chrome so it doesn't
212
218
  * "stick" after tap on touch devices that emulate :hover. */
213
219
  @media (hover: hover) {
214
- .cinder-segmented-control-option:hover:not(:disabled):not([data-cinder-selected]):not(
215
- [data-cinder-pressed]
216
- ) {
220
+ .cinder-segmented-control-option:hover:not(:disabled):not([aria-disabled='true']):not(
221
+ [data-cinder-selected]
222
+ ):not([data-cinder-current]):not([data-cinder-pressed]) {
217
223
  background: color-mix(in oklch, var(--cinder-accent), transparent 90%);
218
224
  color: var(--cinder-text);
219
225
  }
@@ -229,6 +235,9 @@
229
235
  .cinder-segmented-control-option[data-cinder-selected]:hover:not(:disabled):not(
230
236
  [aria-disabled='true']
231
237
  ),
238
+ .cinder-segmented-control-option[data-cinder-current]:hover:not(:disabled):not(
239
+ [aria-disabled='true']
240
+ ),
232
241
  .cinder-segmented-control-option[data-cinder-pressed]:hover:not(:disabled):not(
233
242
  [aria-disabled='true']
234
243
  ) {
@@ -248,7 +257,8 @@
248
257
 
249
258
  /* ── Disabled option ────────────────────────────────────────────────────── */
250
259
 
251
- .cinder-segmented-control-option:disabled {
260
+ .cinder-segmented-control-option:disabled,
261
+ .cinder-segmented-control-option[aria-disabled='true'] {
252
262
  cursor: not-allowed;
253
263
  /* opacity centralized in foundation.css disabled-visual rule (0.6) */
254
264
  }
@@ -261,6 +271,12 @@
261
271
  box-shadow: var(--cinder-shadow-sm);
262
272
  }
263
273
 
274
+ .cinder-segmented-control-option[data-cinder-current] {
275
+ background: var(--cinder-accent);
276
+ color: var(--cinder-accent-contrast);
277
+ box-shadow: var(--cinder-shadow-sm);
278
+ }
279
+
264
280
  .cinder-segmented-control-option-icon {
265
281
  opacity: 0.9;
266
282
  }
@@ -283,7 +299,9 @@
283
299
  .cinder-segmented-control:not([data-cinder-variant='tablist'])
284
300
  .cinder-segmented-control-option[data-cinder-selected]:focus-visible,
285
301
  .cinder-segmented-control:not([data-cinder-variant='tablist'])
286
- .cinder-segmented-control-option[data-cinder-pressed]:focus-visible {
302
+ .cinder-segmented-control-option[data-cinder-pressed]:focus-visible,
303
+ .cinder-segmented-control:not([data-cinder-variant='tablist'])
304
+ .cinder-segmented-control-option[data-cinder-current]:focus-visible {
287
305
  box-shadow:
288
306
  0 0 0 var(--cinder-ring-offset) var(--cinder-ring-offset-color),
289
307
  0 0 0 calc(var(--cinder-ring-offset) + var(--cinder-ring-width)) var(--cinder-ring-color);
@@ -296,7 +314,9 @@
296
314
  .cinder-segmented-control:not([data-cinder-variant='tablist'])
297
315
  .cinder-segmented-control-option[data-cinder-selected]:focus-visible,
298
316
  .cinder-segmented-control:not([data-cinder-variant='tablist'])
299
- .cinder-segmented-control-option[data-cinder-pressed]:focus-visible {
317
+ .cinder-segmented-control-option[data-cinder-pressed]:focus-visible,
318
+ .cinder-segmented-control:not([data-cinder-variant='tablist'])
319
+ .cinder-segmented-control-option[data-cinder-current]:focus-visible {
300
320
  outline: var(--cinder-ring-width) solid Highlight;
301
321
  outline-offset: 1px;
302
322
  box-shadow: none;
@@ -15,6 +15,12 @@
15
15
  "description": "Multiple options can be toggled independently. Pass a SvelteSet to track selection.",
16
16
  "code": "<script lang=\"ts\">\n import { SvelteSet } from 'svelte/reactivity';\n import { Segment } from '@lostgradient/cinder/segment';\n import { SegmentedControl } from '@lostgradient/cinder/segmented-control';\n\n let formats = new SvelteSet<string>();\n\n const selected = $derived([...formats].join(', ') || 'none');\n</script>\n\n<div style=\"display: grid; gap: 0.75rem; justify-items: start;\">\n <SegmentedControl\n id=\"playground-format\"\n selectionMode=\"multiple\"\n value={formats}\n label=\"Text formatting\"\n >\n <Segment value=\"bold\">Bold</Segment>\n <Segment value=\"italic\">Italic</Segment>\n <Segment value=\"underline\">Underline</Segment>\n <Segment value=\"strikethrough\">Strike</Segment>\n </SegmentedControl>\n <p style=\"margin: 0; color: var(--cinder-text-muted);\">Active formats: {selected}</p>\n</div>\n"
17
17
  },
18
+ {
19
+ "id": "navigation",
20
+ "title": "Navigation route filters",
21
+ "description": "Use the navigation variant with linked segments when each option should update the URL.",
22
+ "code": "<script lang=\"ts\">\n import { Segment } from '@lostgradient/cinder/segment';\n import { SegmentedControl } from '@lostgradient/cinder/segmented-control';\n\n const sources = [\n { label: 'Actual', href: '/costs?source=actual', current: true },\n { label: 'Forecast', href: '/costs?source=forecast' },\n { label: 'Blended', href: '/costs?source=blended' },\n ];\n</script>\n\n<SegmentedControl id=\"cost-source-filter\" label=\"Cost source\" variant=\"navigation\">\n {#each sources as source (source.href)}\n <Segment href={source.href} current={source.current}>{source.label}</Segment>\n {/each}\n</SegmentedControl>\n"
23
+ },
18
24
  {
19
25
  "id": "sizing",
20
26
  "title": "Sizing — size vs. toolbar density",
@@ -43,8 +43,8 @@
43
43
  "description": "Stretch the control to fill available width."
44
44
  },
45
45
  "variant": {
46
- "enum": ["radiogroup", "tablist"],
47
- "description": "ARIA interaction pattern. Use `tablist` when options switch visible panels."
46
+ "enum": ["radiogroup", "tablist", "navigation"],
47
+ "description": "ARIA interaction pattern. Use `navigation` for route-backed links."
48
48
  },
49
49
  "class": {
50
50
  "type": "string",
@@ -48,8 +48,8 @@ const schema = {
48
48
  description: 'Stretch the control to fill available width.',
49
49
  },
50
50
  variant: {
51
- enum: ['radiogroup', 'tablist'],
52
- description: 'ARIA interaction pattern. Use `tablist` when options switch visible panels.',
51
+ enum: ['radiogroup', 'tablist', 'navigation'],
52
+ description: 'ARIA interaction pattern. Use `navigation` for route-backed links.',
53
53
  },
54
54
  class: {
55
55
  type: 'string',
@@ -12,9 +12,11 @@ let resetInputElement = $state(null);
12
12
  let resetSyncTimeout;
13
13
  const initialSingleValue = untrack(() => (typeof value === 'string' ? value : undefined));
14
14
  const initialMultipleValues = untrack(() => selectionMode === 'multiple' && value instanceof SvelteSet ? Array.from(value) : undefined);
15
+ const effectiveVariant = $derived(selectionMode === 'multiple' && variant !== 'radiogroup' ? 'radiogroup' : variant);
16
+ const rendersNavigation = $derived(effectiveVariant === 'navigation');
15
17
  const controller = new SegmentedControlController({
16
18
  selectionMode: () => selectionMode,
17
- variant: () => variant,
19
+ variant: () => effectiveVariant,
18
20
  orientation: () => orientation,
19
21
  controlDisabled: () => disabled,
20
22
  disallowEmptySelection: () => disallowEmptySelection,
@@ -29,7 +31,7 @@ const contextValue = {
29
31
  return selectionMode;
30
32
  },
31
33
  get variant() {
32
- return variant;
34
+ return effectiveVariant;
33
35
  },
34
36
  get controlDisabled() {
35
37
  return disabled;
@@ -41,7 +43,13 @@ const contextValue = {
41
43
  };
42
44
  setSegmentedControlContext(contextValue);
43
45
  // See docs/decisions/segmented-control-tablist-variant.md for why tablist remains a SegmentedControl variant.
44
- const groupRole = $derived(selectionMode === 'multiple' ? 'group' : variant === 'tablist' ? 'tablist' : 'radiogroup');
46
+ const groupRole = $derived(selectionMode === 'multiple'
47
+ ? 'group'
48
+ : variant === 'navigation'
49
+ ? undefined
50
+ : variant === 'tablist'
51
+ ? 'tablist'
52
+ : 'radiogroup');
45
53
  // When density="toolbar" is requested it resolves to the compact `sm` visual
46
54
  // size — explicit `size` values are ignored while toolbar density is on so
47
55
  // toolbars line up with sibling toolbar controls. `data-cinder-size` reflects
@@ -94,26 +102,45 @@ $effect(() => {
94
102
  >
95
103
  {label}
96
104
  </span>
97
- <div
98
- {...rest}
99
- {id}
100
- role={groupRole}
101
- aria-labelledby={`${id}-label`}
102
- aria-disabled={disabled ? 'true' : undefined}
103
- aria-orientation={selectionMode === 'single' ? orientation : undefined}
104
- data-cinder-orientation={orientation}
105
- data-cinder-size={effectiveSize}
106
- data-cinder-density={density === 'toolbar' ? 'toolbar' : undefined}
107
- data-cinder-selection-mode={selectionMode}
108
- data-cinder-detached={detached ? '' : undefined}
109
- data-cinder-full-width={fullWidth ? '' : undefined}
110
- data-cinder-variant={variant}
111
- class={classNames('cinder-segmented-control', customClassName)}
112
- onkeydown={(event) => controller.handleKeydown(event)}
113
- >
114
- {@render children()}
115
- </div>
116
- {#if name}
105
+ {#if rendersNavigation}
106
+ <nav
107
+ {...rest}
108
+ {id}
109
+ role="navigation"
110
+ aria-labelledby={`${id}-label`}
111
+ data-cinder-orientation={orientation}
112
+ data-cinder-size={effectiveSize}
113
+ data-cinder-density={density === 'toolbar' ? 'toolbar' : undefined}
114
+ data-cinder-selection-mode={selectionMode}
115
+ data-cinder-detached={detached ? '' : undefined}
116
+ data-cinder-full-width={fullWidth ? '' : undefined}
117
+ data-cinder-variant={effectiveVariant}
118
+ class={classNames('cinder-segmented-control', customClassName)}
119
+ >
120
+ {@render children()}
121
+ </nav>
122
+ {:else}
123
+ <div
124
+ {...rest}
125
+ {id}
126
+ role={groupRole}
127
+ aria-labelledby={`${id}-label`}
128
+ aria-disabled={disabled ? 'true' : undefined}
129
+ aria-orientation={selectionMode === 'single' ? orientation : undefined}
130
+ data-cinder-orientation={orientation}
131
+ data-cinder-size={effectiveSize}
132
+ data-cinder-density={density === 'toolbar' ? 'toolbar' : undefined}
133
+ data-cinder-selection-mode={selectionMode}
134
+ data-cinder-detached={detached ? '' : undefined}
135
+ data-cinder-full-width={fullWidth ? '' : undefined}
136
+ data-cinder-variant={effectiveVariant}
137
+ class={classNames('cinder-segmented-control', customClassName)}
138
+ onkeydown={(event) => controller.handleKeydown(event)}
139
+ >
140
+ {@render children()}
141
+ </div>
142
+ {/if}
143
+ {#if name && !rendersNavigation}
117
144
  <input bind:this={resetInputElement} type="hidden" disabled />
118
145
  {#each selectedValues as selectedValue (selectedValue)}
119
146
  <input type="hidden" {name} value={selectedValue} {disabled} />
@@ -14,10 +14,7 @@ type ComponentOwnedAttributes =
14
14
  | 'onchange'
15
15
  | 'onkeydown';
16
16
 
17
- type CommonProps<T extends string> = Omit<
18
- HTMLAttributes<HTMLDivElement>,
19
- ComponentOwnedAttributes
20
- > & {
17
+ type CommonProps<T extends string> = Omit<HTMLAttributes<HTMLElement>, ComponentOwnedAttributes> & {
21
18
  /** Unique identifier for the control. */
22
19
  id: string;
23
20
  /** Accessible label for the group. */
@@ -53,8 +50,8 @@ type CommonProps<T extends string> = Omit<
53
50
  detached?: boolean | undefined;
54
51
  /** Stretch the control to fill available width. */
55
52
  fullWidth?: boolean | undefined;
56
- /** ARIA interaction pattern. Use `tablist` when options switch visible panels. */
57
- variant?: 'radiogroup' | 'tablist' | undefined;
53
+ /** ARIA interaction pattern. Use `navigation` for route-backed links. */
54
+ variant?: 'radiogroup' | 'tablist' | 'navigation' | undefined;
58
55
  /** Additional class names merged with `.cinder-segmented-control`. */
59
56
  class?: string | undefined;
60
57
  /** Called when the selected value changes (single mode only). */
@@ -82,7 +79,7 @@ type MultipleProps<T extends string> = CommonProps<T> & {
82
79
  value?: SvelteSet<T> | undefined;
83
80
  /** Not applicable in multiple mode — present for Svelte destructuring compatibility. */
84
81
  disallowEmptySelection?: undefined;
85
- /** Tablist semantics are only valid for single selection. */
82
+ /** Tablist and navigation semantics are only valid for single selection. */
86
83
  variant?: 'radiogroup' | undefined;
87
84
  };
88
85
 
@@ -2,5 +2,6 @@ import './sidebar.css';
2
2
  import Sidebar from './sidebar.svelte';
3
3
 
4
4
  export default Sidebar;
5
+ export { SIDEBAR_MOBILE_BREAKPOINT, SIDEBAR_MOBILE_MEDIA_QUERY } from './sidebar.constants.ts';
5
6
  export type { SidebarProps } from './sidebar.types.ts';
6
7
  export { Sidebar };
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Viewport width below which Sidebar uses its mobile drawer behavior.
3
+ *
4
+ * Consumers with app chrome outside the Sidebar can import this value instead
5
+ * of duplicating the component's internal breakpoint.
6
+ */
7
+ export const SIDEBAR_MOBILE_BREAKPOINT = '47.99rem';
8
+
9
+ /**
10
+ * Fully parenthesized media query used by Sidebar to switch from the inline
11
+ * aside to the mobile drawer.
12
+ */
13
+ export const SIDEBAR_MOBILE_MEDIA_QUERY = `(max-width: ${SIDEBAR_MOBILE_BREAKPOINT})`;