@nysds/playground 0.1.0 → 0.2.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "title": "Agency themes",
3
- "description": "The same button, alert, and card under different agency themes. Themes come from the data-nys-theme attribute.",
3
+ "description": "The same button, alert, and card under different agency themes. A nys-theme-* class on any element themes everything inside it.",
4
4
  "html": "<nys-select id=\"theme-select\" label=\"Select an agency theme\" class=\"nys-margin-b-300\">\n <option value=\"default\" label=\"Default (NY.gov)\" selected></option>\n <option value=\"admin\" label=\"Administration\"></option>\n <option value=\"health\" label=\"Health\"></option>\n <option value=\"transportation\" label=\"Transportation\"></option>\n <option value=\"environment\" label=\"Environment\"></option>\n</nys-select>\n\n<div id=\"theme-wrapper\">\n <div class=\"nys-display-flex nys-flex-column nys-flex-gap-300\">\n <nys-button label=\"Primary action\"></nys-button>\n <nys-alert type=\"info\" heading=\"Themed alert\" text=\"This alert's color follows the selected theme.\"></nys-alert>\n <nys-card heading=\"Themed card\" description=\"Card accents follow the theme color too.\"></nys-card>\n </div>\n</div>\n",
5
5
  "css": "",
6
- "js": "const themeSelect = document.getElementById('theme-select');\nconst themeWrapper = document.getElementById('theme-wrapper');\n\nthemeSelect.addEventListener('nys-change', (e) => {\n themeWrapper.setAttribute('data-nys-theme', e.detail.value);\n});\n"
6
+ "js": "const themeSelect = document.getElementById('theme-select');\nconst themeWrapper = document.getElementById('theme-wrapper');\n\nthemeSelect.addEventListener('nys-change', (e) => {\n // The wrapper carries one nys-theme-* class at a time. No class means the\n // default NY.gov theme.\n themeWrapper.className = e.detail.value === 'default' ? '' : `nys-theme-${e.detail.value}`;\n});\n"
7
7
  }
package/src/app.css CHANGED
@@ -141,19 +141,18 @@ body {
141
141
  color: var(--pg-muted);
142
142
  }
143
143
 
144
- /* On a phone the wordmark and "Playground" no longer fit beside the settings
145
- button on one line, so stack them and drop the divider. */
146
- @media (max-width: 640px) {
147
- .home {
148
- padding-inline: 16px;
149
- }
144
+ /* Below about 800px the wordmark and "Playground" no longer fit beside the
145
+ settings button on one line, so stack them, drop the divider, and let the
146
+ wordmark scale down with the window. Its natural width at 48px tall is
147
+ 437px. */
148
+ @media (max-width: 800px) {
150
149
  .home__title {
151
150
  flex-direction: column;
152
151
  align-items: flex-start;
153
152
  gap: 4px;
154
153
  }
155
154
  .home__logo {
156
- width: min(100%, 320px);
155
+ width: min(100%, 437px);
157
156
  height: auto;
158
157
  }
159
158
  .home__title-text {
@@ -163,6 +162,12 @@ body {
163
162
  }
164
163
  }
165
164
 
165
+ @media (max-width: 640px) {
166
+ .home {
167
+ padding-inline: 16px;
168
+ }
169
+ }
170
+
166
171
  .home__lede {
167
172
  margin: 16px 0 24px;
168
173
  max-width: 60ch;
@@ -682,20 +687,6 @@ nys-toggle[hidden] {
682
687
  min-height: 0;
683
688
  }
684
689
 
685
- .split__divider {
686
- flex: 0 0 var(--pg-divider-width);
687
- cursor: col-resize;
688
- background: var(--pg-border);
689
- background-clip: content-box;
690
- border-inline: 4px solid var(--pg-surface);
691
- touch-action: none;
692
- }
693
-
694
- .split__divider:focus-visible {
695
- outline: 2px solid var(--nys-color-focus, #004dd1);
696
- outline-offset: 1px;
697
- }
698
-
699
690
  /* Presentation mode ---------------------------------------------------- */
700
691
 
701
692
  .app--present .toolbar,
@@ -714,9 +705,8 @@ nys-toggle[hidden] {
714
705
  }
715
706
 
716
707
  /*
717
- * Three real columns rather than an absolutely centred mark. Both sides are
718
- * `1fr`, so the mark is exactly centred and the sides shrink into their
719
- * ellipses before anything can overlap it.
708
+ * Two columns: the title side shrinks into its ellipsis, and the controls
709
+ * take the room they need.
720
710
  */
721
711
  .caption {
722
712
  /* Sits above the editor drawer (z-index 5) so tooltips that pop upward
@@ -724,7 +714,7 @@ nys-toggle[hidden] {
724
714
  position: relative;
725
715
  z-index: 10;
726
716
  display: grid;
727
- grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
717
+ grid-template-columns: minmax(0, 1fr) auto;
728
718
  align-items: center;
729
719
  gap: 12px;
730
720
  padding: 8px 16px;
@@ -751,45 +741,31 @@ nys-toggle[hidden] {
751
741
  min-width: 0;
752
742
  }
753
743
 
754
- .caption__center {
755
- display: flex;
756
- align-items: center;
757
- }
758
-
744
+ /*
745
+ * Spaced by visible edges, not by box. A ghost circle button shows only its
746
+ * 24px glyph inside a 40px box, so ghost boxes sit 4px apart and the
747
+ * outlined arrows carry a wider margin: every visible edge then sits 20px
748
+ * from the next, and hovered ghost circles still clear each other. Margins
749
+ * rather than `gap`, because the zero-width tooltips between the buttons
750
+ * would each take a gap of their own.
751
+ */
759
752
  .caption__right {
760
753
  display: flex;
761
754
  align-items: center;
762
755
  justify-self: end;
763
- gap: 8px;
764
- }
765
-
766
- .caption__actions {
767
- display: flex;
768
- align-items: center;
769
- gap: 4px;
770
756
  }
771
757
 
772
- /* The mark matches the previous and next circle buttons, so the three read as
773
- one row of equal shapes. */
774
- .caption__home {
775
- display: inline-flex;
776
- flex: 0 0 auto;
777
- align-items: center;
778
- justify-content: center;
779
- width: 40px;
780
- height: 40px;
781
- color: var(--nys-color-text-reverse);
782
- border-radius: 50%;
758
+ .caption__right > nys-button {
759
+ margin-inline: 2px;
783
760
  }
784
761
 
785
- .caption__home:focus-visible {
786
- outline: 2px solid var(--nys-color-text-reverse);
787
- outline-offset: 2px;
762
+ #prev-button,
763
+ #next-button {
764
+ margin-inline: 10px;
788
765
  }
789
766
 
790
- .caption__mark {
791
- width: 100%;
792
- height: 100%;
767
+ #next-button {
768
+ margin-inline-end: 0;
793
769
  }
794
770
 
795
771
  .caption__notes[hidden] {
@@ -800,16 +776,25 @@ nys-toggle[hidden] {
800
776
  min-width: 0;
801
777
  }
802
778
 
779
+ /* A flex row, so the title or picker gives way and the counter keeps its
780
+ place at the end. */
803
781
  .caption__title {
782
+ display: flex;
783
+ align-items: center;
784
+ min-width: 0;
804
785
  margin: 0;
805
- overflow: hidden;
806
786
  font-size: 1.0625rem;
807
787
  font-weight: 600;
808
788
  line-height: 1.3;
809
- text-overflow: ellipsis;
810
789
  white-space: nowrap;
811
790
  }
812
791
 
792
+ .caption__title > .present-only {
793
+ min-width: 0;
794
+ overflow: hidden;
795
+ text-overflow: ellipsis;
796
+ }
797
+
813
798
  .caption__group {
814
799
  color: var(--nys-color-text-reverse-weak);
815
800
  font-weight: 400;
@@ -818,13 +803,20 @@ nys-toggle[hidden] {
818
803
  /* The slide title doubles as the slide picker while editing. */
819
804
  .caption__picker {
820
805
  display: inline-flex;
806
+ flex: 0 1 auto;
821
807
  align-items: center;
822
808
  gap: 2px;
823
- max-width: 100%;
809
+ min-width: 0;
824
810
  }
825
811
 
812
+ /* A select sizes to its longest option; `field-sizing` sizes it to the
813
+ chosen one instead where the browser supports it, so the chevron hugs
814
+ the title. Either way it can shrink and clip. */
826
815
  .caption__select {
816
+ min-width: 0;
827
817
  max-width: 100%;
818
+ field-sizing: content;
819
+ text-overflow: ellipsis;
828
820
  padding: 2px 2px 2px 4px;
829
821
  border: none;
830
822
  border-radius: 4px;
@@ -875,8 +867,9 @@ nys-toggle[hidden] {
875
867
  display: none;
876
868
  }
877
869
 
870
+ /* The counter rides with the title, set off by a bullet. */
878
871
  .caption__count {
879
- margin: 0;
872
+ margin: 0 0 0 8px;
880
873
  font-variant-numeric: tabular-nums;
881
874
  font-size: 0.9375rem;
882
875
  font-weight: 400;
@@ -884,64 +877,23 @@ nys-toggle[hidden] {
884
877
  white-space: nowrap;
885
878
  }
886
879
 
887
- /* The counter rides with the title on narrow screens instead. */
888
- .caption__count--inline {
889
- display: none;
890
- margin-inline-start: 8px;
880
+ .caption__count:not(:empty)::before {
881
+ content: "\2022 ";
891
882
  }
892
883
 
893
884
  /*
894
- * Narrow screens: two rows. The arrows keep the outer columns and span both
895
- * rows, the title and counter take row one, and the icons take row two. The
896
- * mark has nowhere to go, so it steps aside.
885
+ * Narrow screens: two rows. The title, counter, and slide settings take row
886
+ * one, and the action buttons spread across row two.
897
887
  */
898
888
  @media (max-width: 720px) {
899
889
  .caption {
900
- grid-template-columns: auto minmax(0, 1fr) auto;
901
- grid-template-rows: auto auto;
902
- gap: 4px 8px;
890
+ grid-template-columns: minmax(0, 1fr);
891
+ gap: 6px;
903
892
  }
904
893
 
905
- .caption__left,
906
894
  .caption__right {
907
- display: contents;
908
- }
909
-
910
- .caption__center {
911
- display: none;
912
- }
913
-
914
- #prev-button {
915
- grid-column: 1;
916
- grid-row: 1 / 3;
917
- align-self: center;
918
- }
919
-
920
- .caption__headline {
921
- grid-column: 2;
922
- grid-row: 1;
923
- }
924
-
925
- .caption__actions {
926
- grid-column: 2;
927
- grid-row: 2;
928
- flex-wrap: wrap;
929
- justify-content: center;
930
- }
931
-
932
- #next-button {
933
- grid-column: 3;
934
- grid-row: 1 / 3;
935
- align-self: center;
936
- }
937
-
938
- /* The counter reads better beside the title than alone on the icon row. */
939
- .caption__right > .caption__count {
940
- display: none;
941
- }
942
-
943
- .caption__count--inline {
944
- display: inline;
895
+ justify-self: stretch;
896
+ justify-content: space-between;
945
897
  }
946
898
  }
947
899
 
@@ -1019,6 +971,99 @@ nys-toggle[hidden] {
1019
971
  align-self: flex-start;
1020
972
  }
1021
973
 
974
+ /* Read by screen readers, invisible to everyone else. */
975
+ .visually-hidden {
976
+ position: absolute;
977
+ width: 1px;
978
+ height: 1px;
979
+ margin: -1px;
980
+ padding: 0;
981
+ overflow: hidden;
982
+ clip: rect(0 0 0 0);
983
+ white-space: nowrap;
984
+ border: 0;
985
+ }
986
+
987
+ /* The slide list in Deck settings ------------------------------------ */
988
+
989
+ /* The modal body clips at its edges, and a focus ring reaches 4px past a
990
+ button, so the list keeps that much room on each side. */
991
+ .slide-list {
992
+ display: flex;
993
+ flex-direction: column;
994
+ gap: 4px;
995
+ margin: 0;
996
+ padding: 0 4px;
997
+ list-style: none;
998
+ }
999
+
1000
+ .slide-row {
1001
+ position: relative;
1002
+ display: flex;
1003
+ flex-wrap: wrap;
1004
+ align-items: center;
1005
+ gap: 8px;
1006
+ padding: 4px 0;
1007
+ }
1008
+
1009
+ .slide-row--dragging {
1010
+ opacity: 0.4;
1011
+ }
1012
+
1013
+ /* A line above or below the row the drag would land beside. */
1014
+ .slide-row--drop-before::before,
1015
+ .slide-row--drop-after::after {
1016
+ content: "";
1017
+ position: absolute;
1018
+ inset-inline: 0;
1019
+ height: 2px;
1020
+ background: var(--nys-color-theme, #154973);
1021
+ }
1022
+
1023
+ .slide-row--drop-before::before {
1024
+ top: -3px;
1025
+ }
1026
+
1027
+ .slide-row--drop-after::after {
1028
+ bottom: -3px;
1029
+ }
1030
+
1031
+ /* The button inside sets the pointer cursor itself. Once a drag starts, the
1032
+ browser draws the drag cursor and no CSS reaches it. */
1033
+ .slide-row__handle {
1034
+ flex: 0 0 auto;
1035
+ touch-action: none;
1036
+ }
1037
+
1038
+ .slide-row__position {
1039
+ flex: 0 0 1.5em;
1040
+ font-variant-numeric: tabular-nums;
1041
+ color: var(--pg-muted);
1042
+ text-align: end;
1043
+ }
1044
+
1045
+ .slide-row__title {
1046
+ flex: 1 1 200px;
1047
+ min-width: 0;
1048
+ }
1049
+
1050
+ .slide-row__group {
1051
+ flex: 0 1 200px;
1052
+ min-width: 0;
1053
+ }
1054
+
1055
+ .slide-row__actions {
1056
+ display: flex;
1057
+ flex: 0 0 auto;
1058
+ align-items: center;
1059
+ margin-inline-start: auto;
1060
+ }
1061
+
1062
+ .slide-row--removed .slide-row__title,
1063
+ .slide-row--removed .slide-row__group {
1064
+ text-decoration: line-through;
1065
+ }
1066
+
1022
1067
  /* The base CSS field is the same CodeMirror editor as the CSS pane, boxed
1023
1068
  like a design system text area so it sits with the other fields. */
1024
1069
  .settings__code .settings__legend {
@@ -1046,6 +1091,9 @@ nys-toggle[hidden] {
1046
1091
 
1047
1092
  /* Toast ---------------------------------------------------------------- */
1048
1093
 
1094
+ /* The toast host is a live region that stays in the page. Empty, it has no
1095
+ size, so it needs no hiding: `display: none` would take the region out of
1096
+ the accessibility tree between toasts. */
1049
1097
  .toast {
1050
1098
  position: fixed;
1051
1099
  inset-block-end: 24px;
@@ -1054,10 +1102,6 @@ nys-toggle[hidden] {
1054
1102
  max-width: 320px;
1055
1103
  }
1056
1104
 
1057
- .toast[hidden] {
1058
- display: none;
1059
- }
1060
-
1061
1105
  /* Narrow screens ------------------------------------------------------- */
1062
1106
 
1063
1107
  @media (max-width: 720px) {
package/src/deck-model.ts CHANGED
@@ -35,6 +35,26 @@ export interface StoredDeck {
35
35
  /** The markup a brand new slide starts from. */
36
36
  export const BLANK_SLIDE_HTML = '<nys-button label="Excelsior"></nys-button>\n';
37
37
 
38
+ /**
39
+ * Whether a slide holds anything worth a warning before it is deleted: code
40
+ * in any editor beyond the starter button, or a setting someone changed from
41
+ * what a new slide gets.
42
+ */
43
+ export function hasContent(slide: Slide): boolean {
44
+ const html = slide.html.trim();
45
+ return (
46
+ (html !== '' && html !== BLANK_SLIDE_HTML.trim()) ||
47
+ slide.css.trim() !== '' ||
48
+ slide.js.trim() !== '' ||
49
+ !/^Slide \d+$/.test(slide.title.trim()) ||
50
+ slide.group.trim() !== '' ||
51
+ slide.description.trim() !== '' ||
52
+ slide.notes.trim() !== '' ||
53
+ slide.version !== 'latest' ||
54
+ (slide.editors?.length ?? 0) > 0
55
+ );
56
+ }
57
+
38
58
  /** Turns a title into a slug that is safe in a URL and a filename. */
39
59
  export function slugify(title: string): string {
40
60
  const slug = title
@@ -278,6 +298,33 @@ export function staleStarters(
278
298
  });
279
299
  }
280
300
 
301
+ /** One row of the slide list in Deck settings. */
302
+ export interface SlideEdit {
303
+ id: string;
304
+ title: string;
305
+ group: string;
306
+ /** Marked for removal; the slide goes when the edits are applied. */
307
+ removed?: boolean;
308
+ }
309
+
310
+ /**
311
+ * Applies the slide list's edits: the rows' order, titles, and groups, with
312
+ * removed rows dropped. Code and every other field come from the slide as it
313
+ * is. A blank title keeps the old one, and the result is never empty.
314
+ */
315
+ export function arrangeSlides(slides: readonly Slide[], edits: readonly SlideEdit[]): Slide[] {
316
+ const byId = new Map(slides.map((slide) => [slide.id, slide]));
317
+ const result: Slide[] = [];
318
+ for (const edit of edits) {
319
+ const slide = byId.get(edit.id);
320
+ if (!slide || edit.removed) {
321
+ continue;
322
+ }
323
+ result.push({...slide, title: edit.title.trim() || slide.title, group: edit.group.trim()});
324
+ }
325
+ return result.length > 0 ? result : slides.map((slide) => ({...slide}));
326
+ }
327
+
281
328
  /** Formats an ISO timestamp as "today", "3 days ago", and so on. */
282
329
  export function relativeTime(iso: string, now = new Date()): string {
283
330
  const then = new Date(iso).getTime();
package/src/decks.test.ts CHANGED
@@ -9,7 +9,9 @@ import assert from 'node:assert/strict';
9
9
  import test from 'node:test';
10
10
 
11
11
  import {
12
+ arrangeSlides,
12
13
  copyOfDeck,
14
+ hasContent,
13
15
  makeDeck,
14
16
  makeSlide,
15
17
  missingStarters,
@@ -286,3 +288,56 @@ test('staleStarters finds untouched copies seeded under a retired key', () => {
286
288
  );
287
289
  assert.deepEqual(staleStarters([untouched], retired, []), []);
288
290
  });
291
+
292
+ test('arrangeSlides reorders, renames, regroups, and drops removed slides', () => {
293
+ const slides = [
294
+ makeSlide({id: 'a', title: 'A', html: 'a'}),
295
+ makeSlide({id: 'b', title: 'B', html: 'b', group: 'One'}),
296
+ makeSlide({id: 'c', title: 'C', html: 'c'}),
297
+ ];
298
+ const result = arrangeSlides(slides, [
299
+ {id: 'c', title: 'C renamed', group: ' Two '},
300
+ {id: 'b', title: 'B', group: 'One', removed: true},
301
+ {id: 'a', title: ' ', group: ''},
302
+ ]);
303
+ assert.deepEqual(
304
+ result.map((slide) => [slide.id, slide.title, slide.group, slide.html]),
305
+ [
306
+ ['c', 'C renamed', 'Two', 'c'],
307
+ ['a', 'A', '', 'a'],
308
+ ],
309
+ );
310
+ });
311
+
312
+ test('arrangeSlides ignores unknown rows and never returns an empty deck', () => {
313
+ const slides = [makeSlide({id: 'a', title: 'A', html: 'a'})];
314
+ assert.deepEqual(
315
+ arrangeSlides(slides, [{id: 'ghost', title: 'Ghost', group: ''}]).map((s) => s.id),
316
+ ['a'],
317
+ );
318
+ assert.deepEqual(
319
+ arrangeSlides(slides, [{id: 'a', title: 'A', group: '', removed: true}]).map((s) => s.id),
320
+ ['a'],
321
+ );
322
+ });
323
+
324
+ test('hasContent is false for a slide as Add slide or New deck made it', () => {
325
+ assert.equal(hasContent(makeSlide({id: 'slide-3', title: 'Slide 3'})), false);
326
+ assert.equal(
327
+ hasContent(makeSlide({id: 'slide-1', title: 'Slide 1', html: '<nys-button label="Excelsior"></nys-button>\n'})),
328
+ false,
329
+ );
330
+ });
331
+
332
+ test('hasContent is true once anything was typed or set', () => {
333
+ const blank = {id: 'slide-3', title: 'Slide 3'};
334
+ assert.equal(hasContent(makeSlide({...blank, html: '<p>Hi</p>'})), true);
335
+ assert.equal(hasContent(makeSlide({...blank, css: 'p{}'})), true);
336
+ assert.equal(hasContent(makeSlide({...blank, js: 'x()'})), true);
337
+ assert.equal(hasContent(makeSlide({...blank, title: 'Button'})), true);
338
+ assert.equal(hasContent(makeSlide({...blank, group: 'One'})), true);
339
+ assert.equal(hasContent(makeSlide({...blank, description: 'd'})), true);
340
+ assert.equal(hasContent(makeSlide({...blank, notes: 'n'})), true);
341
+ assert.equal(hasContent(makeSlide({...blank, version: '1.21.0'})), true);
342
+ assert.equal(hasContent(makeSlide({...blank, editors: ['html']})), true);
343
+ });