@usefidel/design-system 0.13.0 → 0.14.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.
package/dist/index.d.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  import * as react from 'react';
2
2
  import { ButtonHTMLAttributes, ReactNode, HTMLAttributes, InputHTMLAttributes } from 'react';
3
3
 
4
- type ButtonVariant = 'primary' | 'outlined' | 'text' | 'neutral' | 'link';
4
+ type ButtonVariant = 'primary' | 'outlined' | 'text' | 'neutral' | 'link'
5
+ /** Final confirm of a destructive action ("Delete my account"). */
6
+ | 'danger'
7
+ /** Starts a destructive action ("Delete account", "Remove"). */
8
+ | 'danger-outlined';
5
9
  type ButtonSize = 'x-small' | 'small' | 'medium' | 'big';
6
10
  interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
7
11
  /** Visual style. @default 'primary' */
@@ -248,4 +252,125 @@ interface ToggleProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'
248
252
  */
249
253
  declare const Toggle: react.ForwardRefExoticComponent<ToggleProps & react.RefAttributes<HTMLInputElement>>;
250
254
 
251
- export { Button, type ButtonProps, type ButtonSize, type ButtonVariant, type DiffElement, type DiffPropertyLine, DiffPropertyList, type DiffPropertyListProps, DiffSection, type DiffSectionProps, MismatchCard, type MismatchCardProps, type MismatchProperty, Select, type SelectOption, type SelectProps, Toggle, type ToggleProps, type ToggleSize };
255
+ interface AppNavProps extends HTMLAttributes<HTMLElement> {
256
+ /** Brand mark, usually a link home. */
257
+ logo: ReactNode;
258
+ /** Right-hand area (account menu, admin tools). Never shrinks. */
259
+ actions?: ReactNode;
260
+ /**
261
+ * The nav links (`<a>` or a router link). Mark the current page with
262
+ * `aria-current="page"`; React Router's `NavLink` does this itself.
263
+ */
264
+ children?: ReactNode;
265
+ /** Accessible label for the mobile menu button. @default 'Menu' */
266
+ menuLabel?: string;
267
+ }
268
+ /**
269
+ * Fidel design-system app navigation bar. Router-agnostic: it styles whatever
270
+ * links it is given. Below 640px the links move into a menu opened from a
271
+ * button at the right of the bar; picking a link, Escape, or a tap outside
272
+ * closes it.
273
+ */
274
+ declare function AppNav({ logo, actions, children, menuLabel, className, ...rest }: AppNavProps): react.JSX.Element;
275
+
276
+ interface TabNavProps extends HTMLAttributes<HTMLElement> {
277
+ /**
278
+ * The tab links (`<a>` or a router link). Mark the current tab with
279
+ * `aria-current="page"`; React Router's `NavLink` does this itself.
280
+ */
281
+ children: ReactNode;
282
+ }
283
+ /**
284
+ * Fidel design-system tab navigation: a row of page-level links with an
285
+ * underline on the current one. Labels never wrap; when the row is wider than
286
+ * its container it scrolls sideways. Give it an `aria-label`.
287
+ */
288
+ declare function TabNav({ children, className, ...rest }: TabNavProps): react.JSX.Element;
289
+
290
+ interface SettingsSectionProps extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
291
+ /** Section heading, rendered as an `h2`. */
292
+ title: ReactNode;
293
+ /** Short muted line under the heading. */
294
+ description?: ReactNode;
295
+ /** The section's controls and content. */
296
+ children?: ReactNode;
297
+ }
298
+ /**
299
+ * Fidel design-system settings section: heading and description on the left,
300
+ * content on the right, divided from the next section by a hairline. Stacks
301
+ * into one column below 900px.
302
+ */
303
+ declare function SettingsSection({ title, description, children, className, ...rest }: SettingsSectionProps): react.JSX.Element;
304
+
305
+ type ModalSize = 'small' | 'medium';
306
+ type ModalTone = 'default' | 'danger';
307
+ interface ModalProps {
308
+ /** Whether the dialog is shown. */
309
+ open: boolean;
310
+ /** Called on Escape, the close button, or a click outside the dialog. */
311
+ onClose: () => void;
312
+ /** Dialog heading; also its accessible name. */
313
+ title: ReactNode;
314
+ /** Muted line under the heading; also its accessible description. */
315
+ description?: ReactNode;
316
+ /** Body content. */
317
+ children?: ReactNode;
318
+ /** Footer buttons, primary action last. Stacked full width on phones. */
319
+ actions?: ReactNode;
320
+ /** Width step: small 420px, medium 560px. @default 'small' */
321
+ size?: ModalSize;
322
+ /** False blocks every way of closing, e.g. while an action is in flight. @default true */
323
+ dismissible?: boolean;
324
+ /** 'danger' marks a destructive confirm with a warning icon; pair it with a `danger` Button. @default 'default' */
325
+ tone?: ModalTone;
326
+ className?: string;
327
+ }
328
+ /**
329
+ * Fidel design-system Modal. Portalled to `document.body`; traps focus, locks
330
+ * page scroll, closes on Escape or a click outside, and returns focus to where
331
+ * it was. Focus starts on the element marked `data-autofocus`, else the first
332
+ * focusable element.
333
+ */
334
+ declare function Modal({ open, onClose, title, description, children, actions, size, dismissible, tone, className, }: ModalProps): react.ReactPortal | null;
335
+
336
+ interface SegmentedOption<T extends string> {
337
+ value: T;
338
+ label: ReactNode;
339
+ /** Optional number shown after the label, e.g. issues in a severity. */
340
+ count?: number;
341
+ /** Id of the panel this option shows, for `aria-controls`. */
342
+ controls?: string;
343
+ disabled?: boolean;
344
+ }
345
+ interface SegmentedControlProps<T extends string> {
346
+ options: SegmentedOption<T>[];
347
+ value: T;
348
+ onChange: (value: T) => void;
349
+ /** Names the group for screen readers. */
350
+ 'aria-label': string;
351
+ className?: string;
352
+ }
353
+ /**
354
+ * Fidel design-system segmented control: pick one of a few options (a mode,
355
+ * a filter). Tabs semantics with a roving tabindex: Arrow keys, Home and End
356
+ * move and select; Tab leaves the group. When the options don't fit, the row
357
+ * scrolls sideways instead of widening the page.
358
+ */
359
+ declare function SegmentedControl<T extends string>({ options, value, onChange, 'aria-label': ariaLabel, className, }: SegmentedControlProps<T>): react.JSX.Element;
360
+
361
+ /**
362
+ * Layout breakpoints, in px, used as `max-width` media queries.
363
+ * CSS custom properties can't be read inside `@media`, so component CSS and
364
+ * consumers write these same numbers literally: `@media (max-width: 640px)`.
365
+ */
366
+ declare const breakpoints: {
367
+ /** Phones. */
368
+ readonly sm: 480;
369
+ /** Large phones, small tablets. */
370
+ readonly md: 640;
371
+ /** Tablets, narrow windows. */
372
+ readonly lg: 900;
373
+ };
374
+ type Breakpoint = keyof typeof breakpoints;
375
+
376
+ export { AppNav, type AppNavProps, type Breakpoint, Button, type ButtonProps, type ButtonSize, type ButtonVariant, type DiffElement, type DiffPropertyLine, DiffPropertyList, type DiffPropertyListProps, DiffSection, type DiffSectionProps, MismatchCard, type MismatchCardProps, type MismatchProperty, Modal, type ModalProps, type ModalSize, type ModalTone, SegmentedControl, type SegmentedControlProps, type SegmentedOption, Select, type SelectOption, type SelectProps, SettingsSection, type SettingsSectionProps, TabNav, type TabNavProps, Toggle, type ToggleProps, type ToggleSize, breakpoints };
package/dist/index.js CHANGED
@@ -571,11 +571,284 @@ var Toggle = forwardRef2(function Toggle2({ size = "medium", label, description,
571
571
  ] })
572
572
  ] });
573
573
  });
574
+
575
+ // src/components/AppNav/AppNav.tsx
576
+ import { useEffect as useEffect2, useId as useId3, useRef as useRef2, useState as useState4 } from "react";
577
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
578
+ function AppNav({
579
+ logo,
580
+ actions,
581
+ children,
582
+ menuLabel = "Menu",
583
+ className,
584
+ ...rest
585
+ }) {
586
+ const [open, setOpen] = useState4(false);
587
+ const linksId = useId3();
588
+ const navRef = useRef2(null);
589
+ const toggleRef = useRef2(null);
590
+ useEffect2(() => {
591
+ if (!open) return;
592
+ const onKey = (e) => {
593
+ if (e.key === "Escape") {
594
+ setOpen(false);
595
+ toggleRef.current?.focus();
596
+ }
597
+ };
598
+ const onPointer = (e) => {
599
+ if (!navRef.current?.contains(e.target)) setOpen(false);
600
+ };
601
+ window.addEventListener("keydown", onKey);
602
+ document.addEventListener("mousedown", onPointer);
603
+ return () => {
604
+ window.removeEventListener("keydown", onKey);
605
+ document.removeEventListener("mousedown", onPointer);
606
+ };
607
+ }, [open]);
608
+ const classes = ["fds-app-nav", className].filter(Boolean).join(" ");
609
+ return /* @__PURE__ */ jsxs6("nav", { ref: navRef, className: classes, ...rest, children: [
610
+ /* @__PURE__ */ jsx6("div", { className: "fds-app-nav__logo", children: logo }),
611
+ /* @__PURE__ */ jsx6(
612
+ "div",
613
+ {
614
+ id: linksId,
615
+ className: "fds-app-nav__links",
616
+ "data-open": open ? "" : void 0,
617
+ onClick: (e) => {
618
+ if (e.target.closest("a, button")) setOpen(false);
619
+ },
620
+ children
621
+ }
622
+ ),
623
+ actions != null && /* @__PURE__ */ jsx6("div", { className: "fds-app-nav__actions", children: actions }),
624
+ /* @__PURE__ */ jsx6(
625
+ "button",
626
+ {
627
+ ref: toggleRef,
628
+ type: "button",
629
+ className: "fds-app-nav__toggle",
630
+ "aria-label": menuLabel,
631
+ "aria-expanded": open,
632
+ "aria-controls": linksId,
633
+ onClick: () => setOpen((v) => !v),
634
+ children: /* @__PURE__ */ jsx6("svg", { width: "20", height: "20", viewBox: "0 0 20 20", fill: "none", "aria-hidden": "true", children: open ? /* @__PURE__ */ jsx6("path", { d: "M5 5l10 10M15 5L5 15", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round" }) : /* @__PURE__ */ jsx6("path", { d: "M3 6h14M3 10h14M3 14h14", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round" }) })
635
+ }
636
+ )
637
+ ] });
638
+ }
639
+
640
+ // src/components/TabNav/TabNav.tsx
641
+ import { jsx as jsx7 } from "react/jsx-runtime";
642
+ function TabNav({ children, className, ...rest }) {
643
+ const classes = ["fds-tab-nav", className].filter(Boolean).join(" ");
644
+ return /* @__PURE__ */ jsx7("nav", { className: classes, ...rest, children });
645
+ }
646
+
647
+ // src/components/SettingsSection/SettingsSection.tsx
648
+ import { useId as useId4 } from "react";
649
+ import { jsx as jsx8, jsxs as jsxs7 } from "react/jsx-runtime";
650
+ function SettingsSection({
651
+ title,
652
+ description,
653
+ children,
654
+ className,
655
+ ...rest
656
+ }) {
657
+ const titleId = useId4();
658
+ const classes = ["fds-settings-section", className].filter(Boolean).join(" ");
659
+ return /* @__PURE__ */ jsxs7("section", { className: classes, "aria-labelledby": titleId, ...rest, children: [
660
+ /* @__PURE__ */ jsxs7("div", { className: "fds-settings-section__head", children: [
661
+ /* @__PURE__ */ jsx8("h2", { id: titleId, className: "fds-settings-section__title", children: title }),
662
+ description != null && /* @__PURE__ */ jsx8("p", { className: "fds-settings-section__description", children: description })
663
+ ] }),
664
+ /* @__PURE__ */ jsx8("div", { className: "fds-settings-section__body", children })
665
+ ] });
666
+ }
667
+
668
+ // src/components/Modal/Modal.tsx
669
+ import { useEffect as useEffect3, useId as useId5, useRef as useRef3 } from "react";
670
+ import { createPortal } from "react-dom";
671
+ import { jsx as jsx9, jsxs as jsxs8 } from "react/jsx-runtime";
672
+ var FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
673
+ function Modal({
674
+ open,
675
+ onClose,
676
+ title,
677
+ description,
678
+ children,
679
+ actions,
680
+ size = "small",
681
+ dismissible = true,
682
+ tone = "default",
683
+ className
684
+ }) {
685
+ const titleId = useId5();
686
+ const descriptionId = useId5();
687
+ const dialogRef = useRef3(null);
688
+ const onCloseRef = useRef3(onClose);
689
+ onCloseRef.current = onClose;
690
+ const dismissibleRef = useRef3(dismissible);
691
+ dismissibleRef.current = dismissible;
692
+ useEffect3(() => {
693
+ if (!open) return;
694
+ const dialog = dialogRef.current;
695
+ const previouslyFocused = document.activeElement;
696
+ const initial = dialog?.querySelector("[data-autofocus]") ?? dialog?.querySelector(`.fds-modal__content ${FOCUSABLE}, .fds-modal__actions ${FOCUSABLE}`) ?? dialog;
697
+ initial?.focus();
698
+ const previousOverflow = document.body.style.overflow;
699
+ document.body.style.overflow = "hidden";
700
+ const onKey = (e) => {
701
+ if (e.key === "Escape" && dismissibleRef.current) {
702
+ e.preventDefault();
703
+ onCloseRef.current();
704
+ return;
705
+ }
706
+ if (e.key === "Tab" && dialog) {
707
+ const focusable = Array.from(dialog.querySelectorAll(FOCUSABLE));
708
+ if (focusable.length === 0) {
709
+ e.preventDefault();
710
+ return;
711
+ }
712
+ const first = focusable[0];
713
+ const last = focusable[focusable.length - 1];
714
+ if (e.shiftKey && (document.activeElement === first || document.activeElement === dialog)) {
715
+ e.preventDefault();
716
+ last.focus();
717
+ } else if (!e.shiftKey && document.activeElement === last) {
718
+ e.preventDefault();
719
+ first.focus();
720
+ }
721
+ }
722
+ };
723
+ window.addEventListener("keydown", onKey);
724
+ return () => {
725
+ window.removeEventListener("keydown", onKey);
726
+ document.body.style.overflow = previousOverflow;
727
+ previouslyFocused?.focus?.();
728
+ };
729
+ }, [open]);
730
+ if (!open || typeof document === "undefined") return null;
731
+ const classes = ["fds-modal", `fds-modal--${size}`, tone === "danger" && "fds-modal--danger", className].filter(Boolean).join(" ");
732
+ return createPortal(
733
+ /* @__PURE__ */ jsx9(
734
+ "div",
735
+ {
736
+ className: "fds-modal__scrim",
737
+ onMouseDown: (e) => {
738
+ if (e.target === e.currentTarget && dismissible) onClose();
739
+ },
740
+ children: /* @__PURE__ */ jsxs8(
741
+ "div",
742
+ {
743
+ ref: dialogRef,
744
+ className: classes,
745
+ role: "dialog",
746
+ "aria-modal": "true",
747
+ "aria-labelledby": titleId,
748
+ "aria-describedby": description != null ? descriptionId : void 0,
749
+ tabIndex: -1,
750
+ children: [
751
+ /* @__PURE__ */ jsx9(
752
+ "button",
753
+ {
754
+ type: "button",
755
+ className: "fds-modal__close",
756
+ onClick: onClose,
757
+ disabled: !dismissible,
758
+ "aria-label": "Close",
759
+ children: /* @__PURE__ */ jsx9("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: /* @__PURE__ */ jsx9("path", { d: "M4 4l8 8M12 4l-8 8", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round" }) })
760
+ }
761
+ ),
762
+ tone === "danger" && /* @__PURE__ */ jsx9("div", { className: "fds-modal__icon", "aria-hidden": "true", children: /* @__PURE__ */ jsxs8("svg", { width: "20", height: "20", viewBox: "0 0 20 20", fill: "none", children: [
763
+ /* @__PURE__ */ jsx9("path", { d: "M10 3.5L17.5 16.5H2.5L10 3.5z", stroke: "currentColor", strokeWidth: "1.5", strokeLinejoin: "round" }),
764
+ /* @__PURE__ */ jsx9("path", { d: "M10 8.5v3.5", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round" }),
765
+ /* @__PURE__ */ jsx9("circle", { cx: "10", cy: "14.25", r: "0.9", fill: "currentColor" })
766
+ ] }) }),
767
+ /* @__PURE__ */ jsx9("h2", { id: titleId, className: "fds-modal__title", children: title }),
768
+ description != null && /* @__PURE__ */ jsx9("p", { id: descriptionId, className: "fds-modal__description", children: description }),
769
+ children != null && /* @__PURE__ */ jsx9("div", { className: "fds-modal__content", children }),
770
+ actions != null && /* @__PURE__ */ jsx9("div", { className: "fds-modal__actions", children: actions })
771
+ ]
772
+ }
773
+ )
774
+ }
775
+ ),
776
+ document.body
777
+ );
778
+ }
779
+
780
+ // src/components/SegmentedControl/SegmentedControl.tsx
781
+ import { useRef as useRef4 } from "react";
782
+ import { jsx as jsx10, jsxs as jsxs9 } from "react/jsx-runtime";
783
+ function SegmentedControl({
784
+ options,
785
+ value,
786
+ onChange,
787
+ "aria-label": ariaLabel,
788
+ className
789
+ }) {
790
+ const refs = useRef4([]);
791
+ const enabled = options.map((o, i) => o.disabled ? -1 : i).filter((i) => i >= 0);
792
+ const onKeyDown = (e, index) => {
793
+ const at = enabled.indexOf(index);
794
+ let next;
795
+ if (e.key === "ArrowRight") next = enabled[(at + 1) % enabled.length];
796
+ else if (e.key === "ArrowLeft") next = enabled[(at - 1 + enabled.length) % enabled.length];
797
+ else if (e.key === "Home") next = enabled[0];
798
+ else if (e.key === "End") next = enabled[enabled.length - 1];
799
+ if (next === void 0) return;
800
+ e.preventDefault();
801
+ onChange(options[next].value);
802
+ refs.current[next]?.focus();
803
+ };
804
+ const classes = ["fds-segmented", className].filter(Boolean).join(" ");
805
+ return /* @__PURE__ */ jsx10("div", { role: "tablist", "aria-label": ariaLabel, className: classes, children: options.map((option, i) => {
806
+ const selected = option.value === value;
807
+ return /* @__PURE__ */ jsxs9(
808
+ "button",
809
+ {
810
+ ref: (el) => {
811
+ refs.current[i] = el;
812
+ },
813
+ type: "button",
814
+ role: "tab",
815
+ "aria-selected": selected,
816
+ "aria-controls": option.controls,
817
+ tabIndex: selected ? 0 : -1,
818
+ disabled: option.disabled,
819
+ className: "fds-segmented__option",
820
+ onClick: () => onChange(option.value),
821
+ onKeyDown: (e) => onKeyDown(e, i),
822
+ children: [
823
+ option.label,
824
+ option.count != null && /* @__PURE__ */ jsx10("span", { className: "fds-segmented__count", children: option.count })
825
+ ]
826
+ },
827
+ option.value
828
+ );
829
+ }) });
830
+ }
831
+
832
+ // src/breakpoints.ts
833
+ var breakpoints = {
834
+ /** Phones. */
835
+ sm: 480,
836
+ /** Large phones, small tablets. */
837
+ md: 640,
838
+ /** Tablets, narrow windows. */
839
+ lg: 900
840
+ };
574
841
  export {
842
+ AppNav,
575
843
  Button,
576
844
  DiffPropertyList,
577
845
  DiffSection,
578
846
  MismatchCard,
847
+ Modal,
848
+ SegmentedControl,
579
849
  Select,
580
- Toggle
850
+ SettingsSection,
851
+ TabNav,
852
+ Toggle,
853
+ breakpoints
581
854
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usefidel/design-system",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Fidel brand design tokens + React component library — DTCG token source of truth, generated per-surface CSS + Tailwind-v4 parser-compatibility output, and on-brand React components (Button, MismatchCard, DiffSection).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",